char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
a6aa6f545
char/aliceai-t5-moe
1#!/usr/bin/env python3 2""" 3Evaluates llama.cpp's DeepSeek-OCR by comparing its output for a test 4image to the actual text in part of that image. 5 6Runs each test image through mtmd-cli, calculates CER and chrF for 7its output, and holds them against the HF model's scores. 8""" 9 10import argparse 11import logging 12import re 13import subprocess 14import sys 15import unicodedata 16from dataclasses import dataclass 17from pathlib import Path 18 19logger = logging .getLogger ("deepseek-ocr-test" ) 20 21RUN_TIMEOUT = 300 22 23 24@dataclass 25class ModelSpec : 26key :str 27label :str 28model_arg :str 29mmproj_arg :str 30model_default :str 31mmproj_default :str 32prompt :str = "Free OCR." 33n_predict :int = 512 34n_ctx :int | None = None 35# Unlimited-OCR's "document parsing" prompt emits <|det|> grounding markup that 36# the HF reference strips in result.md; drop it before scoring to match. 37strip_grounding :bool = False 38# v2/Unlimited loop on hard tiles; DRY caps it the way HF's 39# no_repeat_ngram_size does. v1 scores fine without it. 40dry :bool = False 41 42 43@dataclass 44class TestCase : 45model_key :str 46label :str 47image :str 48ground_truth :str 49hf_cer :float 50hf_chrf :float 51cer_tol :float 52chrf_tol :float 53 54@property 55def cer_max (self )-> float : 56return self .hf_cer + self .cer_tol 57 58@property 59def chrf_min (self )-> float : 60return self .hf_chrf - self .chrf_tol 61 62 63MODELS = { 64"v1" :ModelSpec ( 65key = "v1" ,label = "DeepSeek-OCR" , 66model_arg = "--llama-model" ,mmproj_arg = "--mmproj" , 67model_default = "gguf_models/deepseek-ai/deepseek-ocr-bf16.gguf" , 68mmproj_default = "gguf_models/deepseek-ai/mmproj-deepseek-ocr-bf16.gguf" , 69 ), 70"v2" :ModelSpec ( 71key = "v2" ,label = "DeepSeek-OCR-2" , 72model_arg = "--llama-model-2" ,mmproj_arg = "--mmproj-2" , 73model_default = "gguf_models/deepseek-ai/deepseek-ocr-2-bf16.gguf" , 74mmproj_default = "gguf_models/deepseek-ai/mmproj-deepseek-ocr-2-bf16.gguf" , 75# v2 keeps generating past 512 on multi-tile; give it room to match the HF ref. 76n_predict = 2048 , 77dry = True , 78 ), 79"unlimited" :ModelSpec ( 80key = "unlimited" ,label = "Unlimited-OCR" , 81model_arg = "--llama-model-unlimited" ,mmproj_arg = "--mmproj-unlimited" , 82model_default = "gguf_models/baidu/unlimited-ocr-bf16.gguf" , 83mmproj_default = "gguf_models/baidu/mmproj-unlimited-ocr-bf16.gguf" , 84# "Free OCR." immediately emits EOS on this checkpoint; the HF reference 85# (demo/unlimited_ocr_scores.py) uses "document parsing.", which grounds. 86prompt = "document parsing." , 87# Grounding emits ~3x the tokens of plain OCR, so it needs a larger budget 88# and context to reach the article body the ground truth covers. 89n_predict = 4096 , 90n_ctx = 16384 , 91strip_grounding = True , 92dry = True , 93 ), 94} 95 96CASES = [ 97TestCase ( 98model_key = "v1" ,label = "single-view scan" , 99image = "tools/mtmd/test-1.jpeg" , 100ground_truth = "tools/mtmd/tests/test-1-ground-truth.txt" , 101# Fragile image: the HF ref itself swings ~0.286-0.314 across precision 102# configs -- hence the wide tol. llama.cpp bf16 ~0.322/63.8. 103hf_cer = 0.3140 ,hf_chrf = 67.57 ,cer_tol = 0.04 ,chrf_tol = 5.0 , 104 ), 105TestCase ( 106model_key = "v2" ,label = "single-view scan" , 107image = "tools/mtmd/test-1.jpeg" , 108ground_truth = "tools/mtmd/tests/test-1-ground-truth.txt" , 109# 640x488 is below the 768 tiling threshold -- single 1024 global view. 110# hf_cer/hf_chrf are the deepseek-ai repo's own scores (ImageOps.pad); 111# the transformers HF processor is *not* the reference -- its pad_to_square 112# is one pixel off and lands at ~0.69 instead. 113hf_cer = 0.7761 ,hf_chrf = 28.70 ,cer_tol = 0.12 ,chrf_tol = 8.0 , 114 ), 115TestCase ( 116model_key = "v1" ,label = "multi-tile (dynamic resolution)" , 117image = "tools/mtmd/tests/test-1-positive.png" , 118ground_truth = "tools/mtmd/tests/test-1-ground-truth.txt" , 119# 429x806 -- 806 > 640 triggers the v1 "Gundam" path: (1,2) grid -> 120# 2 local 640 tiles + 1 global 1024 view. Regression guard for the 121# tiling preprocessor -- a broken tile path craters the score. 122# hf_cer/hf_chrf are HF v1's measured scores -- it reads this clean crop exactly. 123hf_cer = 0.0000 ,hf_chrf = 100.00 ,cer_tol = 0.03 ,chrf_tol = 3.0 , 124 ), 125TestCase ( 126model_key = "v2" ,label = "multi-tile (dynamic resolution)" , 127image = "tools/mtmd/tests/test-1-positive.png" , 128ground_truth = "tools/mtmd/tests/test-1-ground-truth.txt" , 129# 429x806 -- 806 > 768 triggers the v2 path: (1,2) grid -> 130# 2 local 768 tiles + 1 global 1024 view = 545 image tokens. 131hf_cer = 0.0236 ,hf_chrf = 97.05 ,cer_tol = 0.03 ,chrf_tol = 3.0 , 132 ), 133TestCase ( 134model_key = "unlimited" ,label = "single-view scan" , 135image = "tools/mtmd/test-1.jpeg" , 136ground_truth = "tools/mtmd/tests/test-1-ground-truth.txt" , 137# HF reference: Unlimited-OCR scoring (gundam, bf16) on this image/ground-truth. 138# Decoder runs full MHA, not R-SWA; the band absorbs that gap + bf16 variance. 139hf_cer = 0.1869 ,hf_chrf = 75.23 ,cer_tol = 0.06 ,chrf_tol = 6.0 , 140 ), 141] 142 143 144GROUNDING_TAG_RE = re .compile (r"<\|(ref|det)\|>.*?<\|/\1\|>" ,re .DOTALL ) 145 146 147def strip_grounding (text :str )-> str : 148"""Drop <|ref|>..<|/ref|> / <|det|>..<|/det|> grounding markup, matching the 149cleaned result.md the HF reference scores against.""" 150return GROUNDING_TAG_RE .sub ("" ,text ) 151 152 153def arg_dest (flag :str )-> str : 154return flag .lstrip ("-" ).replace ("-" ,"_" ) 155 156 157def verdict (ok :bool )-> str : 158return "PASS" if ok else "FAIL" 159 160 161def normalize_text (text :str )-> str : 162"""NFC-normalize and collapse whitespace, so line-wrap and spacing 163don't count as CER errors.""" 164return " " .join (unicodedata .normalize ("NFC" ,text ).split ()) 165 166 167def locally_align (expected :str ,ocr_out :str )-> str : 168"""Return the span of `ocr_out` that best matches `expected`. 169 170The ground truth covers part of the article body. 171But the test image includes half of the newspaper's front page. 172Fuzzy partial-ratio matching picks out 173the body so the unrelated text doesn't disturb CER / chrF. 174""" 175from rapidfuzz import fuzz 176alignment = fuzz .partial_ratio_alignment (expected ,ocr_out ) 177if alignment is None or alignment .dest_end <= alignment .dest_start : 178return ocr_out 179return ocr_out [alignment .dest_start :alignment .dest_end ] 180 181 182def compute_cer (expected :str ,ocr_out :str )-> float : 183"""Character Error Rate. Lower is better. 184CER: fraction of characters you'd insert/delete/substitute to fix the output; 0 = perfect.""" 185import jiwer 186return jiwer .cer (expected ,ocr_out ) 187 188 189def compute_chrf (expected :str ,ocr_out :str )-> float : 190"""chrF score on 0-100. Higher is better. 191chrF: F-score over shared character n-grams; more forgiving of small word/spacing drift than CER. 192""" 193from sacrebleu .metrics import CHRF 194return CHRF ().sentence_score (ocr_out , [expected ]).score 195 196 197def run_mtmd_cli (spec :"ModelSpec" ,model_path ,mmproj_path ,image_path ,bin_path )-> str : 198"""Run mtmd-cli on the image and return its output.""" 199cmd = [ 200str (bin_path ), 201"-m" ,str (model_path ), 202"--mmproj" ,str (mmproj_path ), 203"--image" ,str (image_path ), 204"-p" ,spec .prompt , 205"--chat-template" ,"deepseek-ocr" , 206"--temp" ,"0" , 207"--flash-attn" ,"off" ,# match the HF "eager" attention reference 208"--no-warmup" , 209"-n" ,str (spec .n_predict ),# cap loops on hard images (KV would otherwise fill) 210 ] 211if spec .dry : 212# HF decodes with no_repeat_ngram_size; llama.cpp's analog is DRY. 213# Default DRY breakers include "\n", so they are cleared below. 214cmd += [ 215"--dry-multiplier" ,"0.8" , 216"--dry-base" ,"1.75" , 217"--dry-allowed-length" ,"2" , 218"--dry-penalty-last-n" ,"64" , 219"--dry-sequence-breaker" ,"none" , 220 ] 221if spec .n_ctx is not None : 222cmd += ["-c" ,str (spec .n_ctx )] 223logger .debug (f" command: { ' ' . join ( cmd ) } " ) 224 225try : 226result = subprocess .run (cmd ,capture_output = True ,text = False ,timeout = RUN_TIMEOUT ) 227except subprocess .TimeoutExpired as e : 228if e .stderr : 229logger .error ("llama.cpp stderr:\n%s" ,e .stderr .decode ("utf-8" ,errors = "replace" )) 230raise RuntimeError (f"llama-mtmd-cli timed out after { RUN_TIMEOUT } s" ) 231 232if result .returncode != 0 : 233logger .error ("llama.cpp stderr:\n%s" ,result .stderr .decode ("utf-8" ,errors = "replace" )) 234raise RuntimeError (f"llama-mtmd-cli failed with code { result . returncode } " ) 235 236output = result .stdout .decode ("utf-8" ,errors = "replace" ).strip () 237if spec .strip_grounding : 238output = strip_grounding (output ) 239if not output : 240raise RuntimeError ("llama-mtmd-cli produced no output on stdout" ) 241logger .info (f" output: { len ( output ) } chars" ) 242return output 243 244 245def read_expected_text (file_path :Path )-> str : 246with open (file_path ,"r" ,encoding = "utf-8" )as f : 247return f .read ().strip () 248 249 250def evaluate (case :"TestCase" ,expected :str ,ocr_out :str )-> bool : 251expected = normalize_text (expected ) 252ocr_out = normalize_text (ocr_out ) 253aligned = locally_align (expected ,ocr_out ) 254 255logger .debug (f"\n--- expected (normalized) ---\n { expected } " ) 256logger .debug (f"\n--- OCR output (normalized) ---\n { ocr_out } " ) 257logger .debug (f"\n--- aligned span ---\n { aligned } " ) 258 259cer = compute_cer (expected ,aligned ) 260chrf = compute_chrf (expected ,aligned ) 261 262cer_pass = cer <= case .cer_max 263chrf_pass = chrf >= case .chrf_min 264passed = cer_pass and chrf_pass 265 266logger .info ("" ) 267logger .info ("=" * 60 ) 268logger .info ("OCR evaluation:" ) 269logger .info ("=" * 60 ) 270logger .info (f" CER { cer :>7.4f } (HF { case . hf_cer :.4f } , <= { case . cer_max :>7.4f } -> { verdict ( cer_pass ) } )" ) 271logger .info (f" chrF (0-100) { chrf :>7.2f } (HF { case . hf_chrf :.2f } , >= { case . chrf_min :>7.2f } -> { verdict ( chrf_pass ) } )" ) 272logger .info (f" Expected chars { len ( expected ):>7 } " ) 273logger .info (f" Aligned chars { len ( aligned ):>7 } (of { len ( ocr_out ) } OCR chars)" ) 274logger .info ("" ) 275logger .info (f" Result: { verdict ( passed ) } " ) 276logger .info ("=" * 60 ) 277return passed 278 279 280def argument_parser ()-> argparse .ArgumentParser : 281ap = argparse .ArgumentParser (description = "Compare llama.cpp DeepSeek-OCR output with a ground-truth transcript" ) 282ap .add_argument ("--llama-bin" ,default = "build/bin/llama-mtmd-cli" , 283help = "Path to llama-mtmd-cli binary (relative to repo root or absolute)" ) 284for spec in MODELS .values (): 285ap .add_argument (spec .model_arg ,default = spec .model_default , 286help = f"Path to the { spec . label } GGUF model (relative to repo root or absolute)" ) 287ap .add_argument (spec .mmproj_arg ,default = spec .mmproj_default , 288help = f"Path to the { spec . label } mmproj GGUF file (relative to repo root or absolute)" ) 289ap .add_argument ("--verbose" ,action = "store_true" , 290help = "Also log the expected, OCR, and aligned text" ) 291return ap 292 293 294def configure_logging (verbose :bool )-> None : 295logging .basicConfig (level = logging .DEBUG if verbose else logging .INFO , 296format = "%(message)s" ) 297 298 299def resolve_path (path :str ,base :Path )-> Path : 300p = Path (path ) 301return p if p .is_absolute ()else base / p 302 303 304def main ()-> int : 305args = argument_parser ().parse_args () 306configure_logging (args .verbose ) 307 308repo_root = Path (__file__ ).resolve ().parents [3 ]# tests -> mtmd -> tools -> repo root 309binary = resolve_path (args .llama_bin ,repo_root ) 310 311if not binary .exists (): 312logger .error (f"Error: binary not found: { binary } " ) 313return 1 314 315logger .info ("=" * 60 ) 316logger .info ("DeepSeek-OCR: llama.cpp vs HF parity check" ) 317logger .info ("=" * 60 ) 318 319results = {} 320for case in CASES : 321model_spec = MODELS [case .model_key ] 322title = f" { model_spec . label } -- { case . label } " 323 324logger .info ("" ) 325logger .info (f"=== { title } ===" ) 326 327model = resolve_path (getattr (args ,arg_dest (model_spec .model_arg )),repo_root ) 328mmproj = resolve_path (getattr (args ,arg_dest (model_spec .mmproj_arg )),repo_root ) 329image = resolve_path (case .image ,repo_root ) 330ground_truth = resolve_path (case .ground_truth ,repo_root ) 331 332missing = [(lbl ,p )for lbl ,p in [("model" ,model ), ("mmproj" ,mmproj ), 333 ("image" ,image ), ("ground-truth" ,ground_truth )] 334if not p .exists ()] 335if missing : 336for lbl ,p in missing : 337logger .error (f" Error: { lbl } not found: { p } " ) 338results [title ]= False 339continue 340 341expected = read_expected_text (ground_truth ) 342logger .info (f" Image: { case . image } " ) 343logger .info (f" Expected text: { len ( expected ) } chars" ) 344logger .info (f" Running llama.cpp prompt { model_spec . prompt !r } " ) 345try : 346ocr_out = run_mtmd_cli (model_spec ,model ,mmproj ,image ,binary ) 347except RuntimeError as e : 348logger .error (f" Error: { e } " ) 349results [title ]= False 350continue 351 352results [title ]= evaluate (case ,expected ,ocr_out ) 353 354logger .info ("" ) 355logger .info ("=== Summary ===" ) 356for title ,ok in results .items (): 357logger .info (f" { title :<48 } { verdict ( ok ) } " ) 358all_passed = all (results .values ()) 359logger .info (f"Overall: { verdict ( all_passed ) } " ) 360 361return 0 if all_passed else 1 362 363 364if __name__ == "__main__" : 365sys .exit (main ())