File size: 5,537 Bytes
333ff0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
"""Evaluation: HTR Character Error Rate over a manifest of (image, transcription) lines.

The success metric for Diffu is downstream HTR CER on real Swedish — this module is the reusable
core: run a handwriting recognizer over a manifest and report CER. The recognizer is injectable so
the CER logic is testable without loading a model, and ``HTRRecognizer`` is reused by the Stage-0
VAE recon-CER gate (``model/vae.py``).

    diffu-eval --manifest data_out/val.jsonl --limit 200
"""

from __future__ import annotations

import argparse
from collections.abc import Callable, Sequence

import torch
from PIL import Image
from pydantic import BaseModel

from .data.dataset import load_manifest
from .model.metrics import cer

Recognizer = Callable[[Sequence[Image.Image]], list[str]]


class EvalResult(BaseModel):
    cer: float
    n: int
    samples: list[tuple[str, str]]  # a few (transcription, prediction) pairs to eyeball


def _materialize_trocr_positions(model: torch.nn.Module, device: str) -> None:
    """Work around a transformers-5.x TrOCR bug.

    ``TrOCRSinusoidalPositionalEmbedding.weights`` is a plain attribute (not a registered buffer) built
    under the meta-init context, so it stays on the ``meta`` device, ``.to()`` can't move it, and the
    first forward crashes with "Cannot copy out of meta tensor". Rebuild the (deterministic) sinusoidal
    table on the real device.
    """
    from transformers.models.trocr.modeling_trocr import TrOCRSinusoidalPositionalEmbedding

    for module in model.modules():
        if isinstance(module, TrOCRSinusoidalPositionalEmbedding):
            num = module.weights.shape[0]
            module.weights = module.get_embedding(num, module.embedding_dim, module.padding_idx).to(device)


class HTRRecognizer:
    """Riksarkivet Swedish TrOCR wrapper. Callable on PIL images or a ``[-1, 1]`` image tensor."""

    def __init__(
        self,
        model_id: str = "Riksarkivet/trocr-large-handwritten-hist-swe-3-char",
        device: str | None = None,
        batch_size: int = 16,
    ) -> None:
        from transformers import TrOCRProcessor, VisionEncoderDecoderModel

        self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
        self.batch_size = batch_size
        # local_files_only dodges an online chat-template probe that 404s for the Swedish-Lion repo.
        self.processor = TrOCRProcessor.from_pretrained(model_id, local_files_only=True)
        self.model = (
            VisionEncoderDecoderModel.from_pretrained(model_id, local_files_only=True)
            .to(self.device)
            .eval()
        )
        _materialize_trocr_positions(self.model, self.device)

    @torch.no_grad()
    def __call__(self, images: Sequence[Image.Image] | torch.Tensor) -> list[str]:
        from .recognizer import trim_to_ink

        pils = _tensor_to_pils(images) if isinstance(images, torch.Tensor) else list(images)
        preds: list[str] = []
        for start in range(0, len(pils), self.batch_size):
            # trim_to_ink crops blank canvas so the recognizer reads the writing, not padding — without
            # it a short line on a wide (batch-padded) canvas inflates CER ~25x (proven: 0.42 -> 0.016).
            batch = [trim_to_ink(im.convert("RGB")) for im in pils[start : start + self.batch_size]]
            pv = self.processor(images=batch, return_tensors="pt").pixel_values.to(self.device)
            # interpolate_pos_encoding: the Swedish-Lion was trained at a wide aspect; this lets its
            # ViT encoder accept our variable-width line crops instead of assuming a square input.
            ids = self.model.generate(pv, max_new_tokens=64, interpolate_pos_encoding=True)
            preds.extend(self.processor.batch_decode(ids, skip_special_tokens=True))
        return preds


def _tensor_to_pils(images: torch.Tensor) -> list[Image.Image]:
    """``[B, 3, H, W]`` in ``[-1, 1]`` -> list of PIL images."""
    from torchvision.transforms import functional as TF

    return [TF.to_pil_image(((img.clamp(-1, 1) + 1) / 2).cpu()) for img in images]


def evaluate_manifest(manifest_path: str, recognizer: Recognizer, *, limit: int | None = None) -> EvalResult:
    """Read a manifest, recognize each line image, and return CER vs the transcriptions."""
    rows = load_manifest(manifest_path)
    if limit is not None:
        rows = rows[:limit]
    gts = [r["text"] for r in rows]
    images = [_open(r["image"]) for r in rows]
    preds = recognizer(images)
    return EvalResult(
        cer=cer(preds, gts),
        n=len(rows),
        samples=list(zip(gts[:5], preds[:5], strict=False)),
    )


def _open(path: str) -> Image.Image:
    with Image.open(path) as im:
        return im.convert("RGB")


def main() -> None:
    ap = argparse.ArgumentParser(description="HTR CER over a line manifest (the Diffu success metric).")
    ap.add_argument("--manifest", required=True, help="jsonl with {image, text} rows (e.g. val.jsonl)")
    ap.add_argument("--htr", default="Riksarkivet/trocr-large-handwritten-hist-swe-3-char")
    ap.add_argument("--limit", type=int, default=None)
    ap.add_argument("--batch-size", type=int, default=16)
    args = ap.parse_args()

    recognizer = HTRRecognizer(args.htr, batch_size=args.batch_size)
    result = evaluate_manifest(args.manifest, recognizer, limit=args.limit)
    print(f"CER {result.cer:.4f} over {result.n} lines")
    for gt, pred in result.samples:
        print(f"  gt={gt!r}  pred={pred!r}")


if __name__ == "__main__":
    main()