Spaces:
Running on Zero
Running on Zero
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()
|