small-test / src /ocr_gen.py
Serveurperso's picture
Serveurperso HF Staff
small-test: a 95M multimodal fixture for the llama.cpp server CI
4a393d1
Raw History Blame Contribute Delete
5.9 kB
# synthetic OCR samples: render text with PIL, return (PIL image, messages)
import random, string, os, glob, json, re
from PIL import Image, ImageDraw, ImageFont, ImageFilter
FONTS=[f for f in glob.glob("/usr/share/fonts/truetype/**/*.ttf",recursive=True) if re.search(r"DejaVu(Sans|Serif|SansMono)(-Bold)?\.ttf|Liberation(Sans|Serif|Mono)-(Regular|Bold)\.ttf|Ubuntu(Mono)?\[wght\]|NotoSansMono-Regular|UbuntuSans\[",f)]
assert FONTS, "no fonts"
PROMPTS=["What does the text in the image say?","Read the text in this image.","OCR this image.","Transcribe the text in the image.","What is written in this image?","Extract the text from the image.","Please read this image."]
_sent=None
def sentences():
global _sent
if _sent is None:
_sent=[]
for l in open("data/rendered/smoltalk_eval.jsonl"):
t=json.loads(l)["text"]
for m in re.finditer(r"<\|im_start\|>(?:user|assistant)\n(.*?)<\|im_end\|>",t,re.S):
for s in re.split(r"(?<=[.!?])\s+|\n+",m.group(1)):
s=s.strip()
if 3<=len(s)<=90 and s.isprintable() and s.isascii() and not s.startswith(("<","#","```","-","*","|")): _sent.append(s)
if len(_sent)>200000: break
return _sent
def rand_word(rng,n): return "".join(rng.choice(string.ascii_letters+string.digits) for _ in range(n))
def rand_text(rng):
k=rng.random()
if k<0.45: return rng.choice(sentences())
if k<0.60: # multi-line paragraph
return "\n".join(rng.choice(sentences()) for _ in range(rng.randint(2,4)))
if k<0.70: return str(rng.randint(0,10**rng.randint(1,9)))
if k<0.78: return f"{rng.randint(1,12):02d}/{rng.randint(1,28):02d}/{rng.randint(1990,2030)}"
if k<0.85: return (rand_word(rng,rng.randint(4,12)) if rng.random()<0.5 else " ".join(rng.choice(sentences()).split()[:rng.randint(1,3)])).upper()
if k<0.92: return f"{rand_word(rng,rng.randint(3,8)).lower()}@{rng.choice(['example.com','mail.org','test.net'])}"
if k<0.94: return " ".join(rng.choice(sentences()).split()[:rng.randint(1,4)])
if k<0.98: return rng.choice([f"{rand_word(rng,rng.randint(3,6)).lower()} {rng.randint(1000,2999)}",f"{rand_word(rng,rng.randint(2,5)).upper()}-{rng.randint(10,999)}",f"{rand_word(rng,rng.randint(3,6))}{rng.randint(1,99)}"])
return f"${rng.randint(1,9999)}.{rng.randint(0,99):02d}"
def render(rng,text):
fpath=rng.choice(FONTS); size=rng.randint(16,40)
lines=text.split("\n")
pad=rng.randint(4,24)
dummy=ImageDraw.Draw(Image.new("RGB",(1,1)))
while True:
font=ImageFont.truetype(fpath,size)
ws=[dummy.textbbox((0,0),l,font=font)[2] for l in lines]; hs=[dummy.textbbox((0,0),l,font=font)[3] for l in lines]
if max(ws)+2*pad<=768 or size<=10: break
size-=2
lh=max(hs)+rng.randint(0,8); W=max(ws)+2*pad; H=lh*len(lines)+2*pad
W=min(max(W,32),768); H=min(max(H,32),512)
bg=tuple(rng.randint(200,255) for _ in range(3)) if rng.random()<0.8 else tuple(rng.randint(0,60) for _ in range(3))
fg=tuple(rng.randint(0,70) for _ in range(3)) if sum(bg)>380 else tuple(rng.randint(190,255) for _ in range(3))
img=Image.new("RGB",(W,H),bg); d=ImageDraw.Draw(img)
for i,l in enumerate(lines): d.text((pad,pad+i*lh),l,font=font,fill=fg)
if rng.random()<0.3: img=img.filter(ImageFilter.GaussianBlur(rng.uniform(0.3,1.0)))
if rng.random()<0.2: img=img.rotate(rng.uniform(-3,3),expand=False,fillcolor=bg)
return img
def make_sample(rng):
text=rand_text(rng)
img=render(rng,text)
parts=[{"type":"image"},{"type":"text","text":rng.choice(PROMPTS)}]
if rng.random()<0.5: parts.reverse()
msgs=[{"role":"user","content":parts},{"role":"assistant","content":text}]
return img,msgs
if __name__=="__main__":
rng=random.Random(0); os.makedirs("ocr_samples",exist_ok=True)
for i in range(8):
img,m=make_sample(rng); img.save(f"ocr_samples/{i}.png"); print(img.size,repr(m[1]["content"]))
print(len(FONTS),"fonts")
# ---- preprocessing that mirrors llama.cpp (mtmd) for Qwen3-VL: smart_resize target + align-corners bilinear
import numpy as np
from transformers.models.qwen2_vl.image_processing_qwen2_vl import smart_resize
def lcpp_bilinear(img, W, H):
src=np.asarray(img.convert("RGB")).astype(np.float32); h,w=src.shape[:2]
xr=(w-1)/(W-1) if W>1 else 0.0; yr=(h-1)/(H-1) if H>1 else 0.0
px=np.arange(W)*xr; py=np.arange(H)*yr
x0=np.minimum(px.astype(np.int64),w-1); y0=np.minimum(py.astype(np.int64),h-1)
x1=np.minimum(x0+1,w-1); y1=np.minimum(y0+1,h-1)
xf=(px-x0)[None,:,None]; yf=(py-y0)[:,None,None]
out=(src[y0[:,None],x0[None,:]]*(1-xf)*(1-yf)+src[y0[:,None],x1[None,:]]*xf*(1-yf)+src[y1[:,None],x0[None,:]]*(1-xf)*yf+src[y1[:,None],x1[None,:]]*xf*yf)
return Image.fromarray(np.clip(np.round(out),0,255).astype(np.uint8))
def target_size(img, min_pixels=8*1024, max_pixels=4096*1024):
h,w=smart_resize(img.height,img.width,factor=32,min_pixels=min_pixels,max_pixels=max_pixels); return w,h
def letterbox_lcpp(img, W, H):
# llama.cpp (mtmd, PAD_CEIL): aspect-preserving bilinear resize, centered on a black canvas
import math
img=img.convert("RGB"); sc=min(W/img.width,H/img.height)
nw=min(math.ceil(img.width*sc),W); nh=min(math.ceil(img.height*sc),H)
canvas=Image.new("RGB",(W,H),(0,0,0)); canvas.paste(lcpp_bilinear(img,nw,nh),((W-nw)//2,(H-nh)//2)); return canvas
def preprocess(img, ip, rng=None, return_tensors="np"):
# rng=None -> exact llama.cpp preprocessing; with rng -> 75% llama.cpp style, 25% HF-style stretch with random kernels
W,H=target_size(img)
if rng is not None and rng.random()<0.25:
img=img.convert("RGB").resize((W,H),rng.choice([Image.BILINEAR,Image.BICUBIC,Image.NEAREST,Image.LANCZOS]))
else:
img=letterbox_lcpp(img,W,H)
return ip(images=[img],do_resize=False,return_tensors=return_tensors)