char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Oliver Simonssampler : remove "full-context windows" from history-based samplers (#26524)a6aa6f545

char/aliceai-t5-moe
13.4 KiB365 linesraw
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:
26    key: str
27    label: str
28    model_arg: str
29    mmproj_arg: str
30    model_default: str
31    mmproj_default: str
32    prompt: str = "Free OCR."
33    n_predict: int = 512
34    n_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.
37    strip_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.
40    dry: bool = False
41
42
43@dataclass
44class TestCase:
45    model_key: str
46    label: str
47    image: str
48    ground_truth: str
49    hf_cer: float
50    hf_chrf: float
51    cer_tol: float
52    chrf_tol: float
53
54    @property
55    def cer_max(self) -> float:
56        return self.hf_cer + self.cer_tol
57
58    @property
59    def chrf_min(self) -> float:
60        return self.hf_chrf - self.chrf_tol
61
62
63MODELS = {
64    "v1": ModelSpec(
65        key="v1", label="DeepSeek-OCR",
66        model_arg="--llama-model", mmproj_arg="--mmproj",
67        model_default="gguf_models/deepseek-ai/deepseek-ocr-bf16.gguf",
68        mmproj_default="gguf_models/deepseek-ai/mmproj-deepseek-ocr-bf16.gguf",
69    ),
70    "v2": ModelSpec(
71        key="v2", label="DeepSeek-OCR-2",
72        model_arg="--llama-model-2", mmproj_arg="--mmproj-2",
73        model_default="gguf_models/deepseek-ai/deepseek-ocr-2-bf16.gguf",
74        mmproj_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.
76        n_predict=2048,
77        dry=True,
78    ),
79    "unlimited": ModelSpec(
80        key="unlimited", label="Unlimited-OCR",
81        model_arg="--llama-model-unlimited", mmproj_arg="--mmproj-unlimited",
82        model_default="gguf_models/baidu/unlimited-ocr-bf16.gguf",
83        mmproj_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.
86        prompt="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.
89        n_predict=4096,
90        n_ctx=16384,
91        strip_grounding=True,
92        dry=True,
93    ),
94}
95
96CASES = [
97    TestCase(
98        model_key="v1", label="single-view scan",
99        image="tools/mtmd/test-1.jpeg",
100        ground_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.
103        hf_cer=0.3140, hf_chrf=67.57, cer_tol=0.04, chrf_tol=5.0,
104    ),
105    TestCase(
106        model_key="v2", label="single-view scan",
107        image="tools/mtmd/test-1.jpeg",
108        ground_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.
113        hf_cer=0.7761, hf_chrf=28.70, cer_tol=0.12, chrf_tol=8.0,
114    ),
115    TestCase(
116        model_key="v1", label="multi-tile (dynamic resolution)",
117        image="tools/mtmd/tests/test-1-positive.png",
118        ground_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.
123        hf_cer=0.0000, hf_chrf=100.00, cer_tol=0.03, chrf_tol=3.0,
124    ),
125    TestCase(
126        model_key="v2", label="multi-tile (dynamic resolution)",
127        image="tools/mtmd/tests/test-1-positive.png",
128        ground_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.
131        hf_cer=0.0236, hf_chrf=97.05, cer_tol=0.03, chrf_tol=3.0,
132    ),
133    TestCase(
134        model_key="unlimited", label="single-view scan",
135        image="tools/mtmd/test-1.jpeg",
136        ground_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.
139        hf_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
149    cleaned result.md the HF reference scores against."""
150    return GROUNDING_TAG_RE.sub("", text)
151
152
153def arg_dest(flag: str) -> str:
154    return flag.lstrip("-").replace("-", "_")
155
156
157def verdict(ok: bool) -> str:
158    return "PASS" if ok else "FAIL"
159
160
161def normalize_text(text: str) -> str:
162    """NFC-normalize and collapse whitespace, so line-wrap and spacing
163    don't count as CER errors."""
164    return " ".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
170    The ground truth covers part of the article body.
171    But the test image includes half of the newspaper's front page.
172    Fuzzy partial-ratio matching picks out
173    the body so the unrelated text doesn't disturb CER / chrF.
174    """
175    from rapidfuzz import fuzz
176    alignment = fuzz.partial_ratio_alignment(expected, ocr_out)
177    if alignment is None or alignment.dest_end <= alignment.dest_start:
178        return ocr_out
179    return 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.
184    CER: fraction of characters you'd insert/delete/substitute to fix the output; 0 = perfect."""
185    import jiwer
186    return 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.
191    chrF: F-score over shared character n-grams; more forgiving of small word/spacing drift than CER.
192    """
193    from sacrebleu.metrics import CHRF
194    return 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."""
199    cmd = [
200        str(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    ]
211    if 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.
214        cmd += [
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        ]
221    if spec.n_ctx is not None:
222        cmd += ["-c", str(spec.n_ctx)]
223    logger.debug(f"  command: {' '.join(cmd)}")
224
225    try:
226        result = subprocess.run(cmd, capture_output=True, text=False, timeout=RUN_TIMEOUT)
227    except subprocess.TimeoutExpired as e:
228        if e.stderr:
229            logger.error("llama.cpp stderr:\n%s", e.stderr.decode("utf-8", errors="replace"))
230        raise RuntimeError(f"llama-mtmd-cli timed out after {RUN_TIMEOUT}s")
231
232    if result.returncode != 0:
233        logger.error("llama.cpp stderr:\n%s", result.stderr.decode("utf-8", errors="replace"))
234        raise RuntimeError(f"llama-mtmd-cli failed with code {result.returncode}")
235
236    output = result.stdout.decode("utf-8", errors="replace").strip()
237    if spec.strip_grounding:
238        output = strip_grounding(output)
239    if not output:
240        raise RuntimeError("llama-mtmd-cli produced no output on stdout")
241    logger.info(f"  output: {len(output)} chars")
242    return output
243
244
245def read_expected_text(file_path: Path) -> str:
246    with open(file_path, "r", encoding="utf-8") as f:
247        return f.read().strip()
248
249
250def evaluate(case: "TestCase", expected: str, ocr_out: str) -> bool:
251    expected = normalize_text(expected)
252    ocr_out = normalize_text(ocr_out)
253    aligned = locally_align(expected, ocr_out)
254
255    logger.debug(f"\n--- expected (normalized) ---\n{expected}")
256    logger.debug(f"\n--- OCR output (normalized) ---\n{ocr_out}")
257    logger.debug(f"\n--- aligned span ---\n{aligned}")
258
259    cer = compute_cer(expected, aligned)
260    chrf = compute_chrf(expected, aligned)
261
262    cer_pass = cer <= case.cer_max
263    chrf_pass = chrf >= case.chrf_min
264    passed = cer_pass and chrf_pass
265
266    logger.info("")
267    logger.info("=" * 60)
268    logger.info("OCR evaluation:")
269    logger.info("=" * 60)
270    logger.info(f"  CER               {cer:>7.4f}    (HF {case.hf_cer:.4f}, <= {case.cer_max:>7.4f}  -> {verdict(cer_pass)})")
271    logger.info(f"  chrF (0-100)      {chrf:>7.2f}    (HF {case.hf_chrf:.2f}, >= {case.chrf_min:>7.2f}  -> {verdict(chrf_pass)})")
272    logger.info(f"  Expected chars    {len(expected):>7}")
273    logger.info(f"  Aligned chars     {len(aligned):>7} (of {len(ocr_out)} OCR chars)")
274    logger.info("")
275    logger.info(f"  Result: {verdict(passed)}")
276    logger.info("=" * 60)
277    return passed
278
279
280def argument_parser() -> argparse.ArgumentParser:
281    ap = argparse.ArgumentParser(description="Compare llama.cpp DeepSeek-OCR output with a ground-truth transcript")
282    ap.add_argument("--llama-bin", default="build/bin/llama-mtmd-cli",
283                    help="Path to llama-mtmd-cli binary (relative to repo root or absolute)")
284    for spec in MODELS.values():
285        ap.add_argument(spec.model_arg, default=spec.model_default,
286                        help=f"Path to the {spec.label} GGUF model (relative to repo root or absolute)")
287        ap.add_argument(spec.mmproj_arg, default=spec.mmproj_default,
288                        help=f"Path to the {spec.label} mmproj GGUF file (relative to repo root or absolute)")
289    ap.add_argument("--verbose", action="store_true",
290                    help="Also log the expected, OCR, and aligned text")
291    return ap
292
293
294def configure_logging(verbose: bool) -> None:
295    logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO,
296                        format="%(message)s")
297
298
299def resolve_path(path: str, base: Path) -> Path:
300    p = Path(path)
301    return p if p.is_absolute() else base / p
302
303
304def main() -> int:
305    args = argument_parser().parse_args()
306    configure_logging(args.verbose)
307
308    repo_root = Path(__file__).resolve().parents[3]  # tests -> mtmd -> tools -> repo root
309    binary = resolve_path(args.llama_bin, repo_root)
310
311    if not binary.exists():
312        logger.error(f"Error: binary not found: {binary}")
313        return 1
314
315    logger.info("=" * 60)
316    logger.info("DeepSeek-OCR: llama.cpp vs HF parity check")
317    logger.info("=" * 60)
318
319    results = {}
320    for case in CASES:
321        model_spec = MODELS[case.model_key]
322        title = f"{model_spec.label} -- {case.label}"
323
324        logger.info("")
325        logger.info(f"=== {title} ===")
326
327        model = resolve_path(getattr(args, arg_dest(model_spec.model_arg)), repo_root)
328        mmproj = resolve_path(getattr(args, arg_dest(model_spec.mmproj_arg)), repo_root)
329        image = resolve_path(case.image, repo_root)
330        ground_truth = resolve_path(case.ground_truth, repo_root)
331
332        missing = [(lbl, p) for lbl, p in [("model", model), ("mmproj", mmproj),
333                                           ("image", image), ("ground-truth", ground_truth)]
334                   if not p.exists()]
335        if missing:
336            for lbl, p in missing:
337                logger.error(f"  Error: {lbl} not found: {p}")
338            results[title] = False
339            continue
340
341        expected = read_expected_text(ground_truth)
342        logger.info(f"  Image: {case.image}")
343        logger.info(f"  Expected text: {len(expected)} chars")
344        logger.info(f"  Running llama.cpp prompt {model_spec.prompt!r}")
345        try:
346            ocr_out = run_mtmd_cli(model_spec, model, mmproj, image, binary)
347        except RuntimeError as e:
348            logger.error(f"  Error: {e}")
349            results[title] = False
350            continue
351
352        results[title] = evaluate(case, expected, ocr_out)
353
354    logger.info("")
355    logger.info("=== Summary ===")
356    for title, ok in results.items():
357        logger.info(f"  {title:<48} {verdict(ok)}")
358    all_passed = all(results.values())
359    logger.info(f"Overall: {verdict(all_passed)}")
360
361    return 0 if all_passed else 1
362
363
364if __name__ == "__main__":
365    sys.exit(main())