grinpress / generate_data.py
skyuu72's picture
grinpress v1.0: weights, code, model card, samples :3
80a454b verified
Raw History Blame Contribute Delete
10.3 kB
"""Synthetic 'vintage newspaper print' face+text dataset generator.
Each image: aged newsprint background, one halftone-filled face (smiley/sad/meh) on the
left, a short wood-type/serif text string on the right, distressed + ink-bled.
"""
import argparse, json, math, os, random, urllib.request
import numpy as np, cv2
from multiprocessing import Pool
from PIL import Image, ImageDraw, ImageFont
HERE = os.path.dirname(os.path.abspath(__file__))
FONT_DIR = os.path.join(HERE, "fonts")
FONT_URLS = [ # verified raw URLs (tried in order); at least one must succeed
"https://raw.githubusercontent.com/google/fonts/main/ofl/oldstandardtt/OldStandard-Bold.ttf",
"https://raw.githubusercontent.com/google/fonts/main/ofl/abrilfatface/AbrilFatface-Regular.ttf",
"https://raw.githubusercontent.com/google/fonts/main/apache/ultra/Ultra-Regular.ttf",
"https://raw.githubusercontent.com/google/fonts/main/ofl/rye/Rye-Regular.ttf",
"https://raw.githubusercontent.com/google/fonts/main/ofl/oldstandardtt/OldStandard-Regular.ttf",
]
PALETTE = {
"red": (200, 40, 40), "orange": (230, 120, 30), "yellow": (235, 195, 40),
"green": (50, 150, 60), "teal": (30, 140, 140), "blue": (40, 80, 190),
"navy": (25, 35, 110), "purple": (120, 50, 160), "pink": (230, 100, 160),
"brown": (130, 80, 40), "gray": (120, 120, 120), "cyan": (40, 180, 220),
}
EXPRESSIONS = ["smiley", "sad", "meh"]
WORDS = ["NEWS", "HOT", "EXTRA", "WAR", "SALE", "HELLO", "OK", "YES", "NO", "WOW", "GOOD",
"BAD", "MEH", "JOY", "FUN", "SAD", "DAILY", "TIMES", "POST", "FREE", "NEW", "BIG",
"SOLD", "WIN", "LOVE", "HAPPY", "GLUM", "DEAL", "1923", "VOTE"]
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
def ensure_fonts():
"""Download fonts with urllib if missing; return list of usable TTF paths."""
os.makedirs(FONT_DIR, exist_ok=True)
paths = []
for url in FONT_URLS:
p = os.path.join(FONT_DIR, url.rsplit("/", 1)[1])
if not os.path.exists(p) or os.path.getsize(p) < 10000:
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=30) as r, open(p + ".tmp", "wb") as f:
f.write(r.read())
os.replace(p + ".tmp", p)
except Exception as e:
print(f"[font] failed {url}: {e}")
continue
try:
ImageFont.truetype(p, 20) # validate it parses
paths.append(p)
except Exception as e:
print(f"[font] invalid {p}: {e}")
if not paths:
raise RuntimeError("Could not download any TTF font; check network access. "
"PIL default font is intentionally not used.")
return paths
def random_text(rng):
if rng.random() < 0.6:
return rng.choice(WORDS)
return "".join(rng.choice(LETTERS) for _ in range(rng.randint(1, 8)))
def newsprint(S, rng, fonts):
"""Aged paper: yellowed base, low-freq blotches, coarse fibers, faint background text."""
nprng = np.random.default_rng(rng.randrange(2**31))
base = np.array([228, 216, 186], np.float32) + nprng.uniform(-10, 10, 3)
img = np.ones((S, S, 3), np.float32) * base
low = cv2.resize(nprng.normal(0, 1, (8, 8)).astype(np.float32), (S, S), interpolation=cv2.INTER_CUBIC)
img += low[..., None] * np.array([6, 7, 12]) # uneven yellowing (more in blue channel)
yy, xx = np.mgrid[0:S, 0:S] / S - 0.5
img -= (xx**2 + yy**2)[..., None] * np.array([30, 40, 70]) # browned edges vignette
img += nprng.normal(0, 4, (S, S, 1)) # grain
fib = np.zeros((S, S), np.uint8)
for _ in range(int(S * S / 400)): # coarse fibers: short dark/light strokes
x, y = nprng.integers(0, S, 2); a = nprng.uniform(0, math.pi); L = nprng.uniform(S/60, S/18)
cv2.line(fib, (int(x), int(y)), (int(x + L*math.cos(a)), int(y + L*math.sin(a))),
int(nprng.integers(30, 90)), 1, cv2.LINE_AA)
img -= cv2.GaussianBlur(fib, (0, 0), 0.6)[..., None].astype(np.float32) * 0.35
# faint random background text (bleed-through from the other side)
bt = Image.new("L", (S, S), 0); d = ImageDraw.Draw(bt)
f = ImageFont.truetype(rng.choice(fonts), max(6, S // 22))
y = -rng.randint(0, S // 20)
while y < S:
line = " ".join("".join(rng.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(rng.randint(2, 7)))
for _ in range(12))
d.text((-rng.randint(0, S // 8), y), line, fill=255, font=f)
y += int(S / 18)
bt = np.asarray(bt, np.float32) / 255
img -= bt[..., None] * rng.uniform(14, 28)
return img
def halftone(S, cx, cy, r, rng):
"""Halftone dot screen: 45° grid, dot radius ~ sqrt(ink coverage) with a shading gradient."""
m = np.zeros((S, S), np.uint8)
cell = S / rng.uniform(26, 34)
ang = math.radians(45 + rng.uniform(-5, 5)); ca, sa = math.cos(ang), math.sin(ang)
lx, ly = -0.5, -0.6 # light direction for shading
n = int(S / cell) + 4
for i in range(-n, n):
for j in range(-n, n):
u, v = i * cell, j * cell
x, y = cx + u*ca - v*sa, cy + u*sa + v*ca
dx, dy = (x - cx) / r, (y - cy) / r
if dx*dx + dy*dy > 1.05: continue
cov = np.clip(0.55 - 0.3*(dx*lx + dy*ly), 0.15, 0.9) # coverage in [0,1]
rad = cell * math.sqrt(cov / math.pi) # area ∝ coverage
cv2.circle(m, (int(x*4), int(y*4)), max(1, int(rad*4)), 255, -1, cv2.LINE_AA, shift=2)
circ = np.zeros_like(m); cv2.circle(circ, (int(cx), int(cy)), int(r), 255, -1, cv2.LINE_AA)
return (m.astype(np.float32) / 255) * (circ.astype(np.float32) / 255)
def face_ink(S, cx, cy, r, expr, rng):
m = np.zeros((S, S), np.uint8); th = max(2, int(r * 0.09))
cv2.circle(m, (int(cx), int(cy)), int(r), 255, th, cv2.LINE_AA)
er = max(2, int(r * 0.11))
for sx in (-1, 1):
cv2.circle(m, (int(cx + sx*r*0.35), int(cy - r*0.28)), er, 255, -1, cv2.LINE_AA)
mw, my = r * 0.5, cy + r * 0.32
pts = []
for k in range(25):
u = -1 + 2 * k / 24
bend = {"smiley": 0.28, "sad": -0.28, "meh": 0.0}[expr]
y = my + bend * r * (1 - u*u) - bend * r * 0.5 # parabola: up-curve smile, down frown
pts.append((cx + u * mw, y + (0 if expr != "sad" else r * 0.12)))
cv2.polylines(m, [np.round(np.array(pts) * 4).astype(np.int32)], False, 255, th, cv2.LINE_AA, shift=2)
return m.astype(np.float32) / 255
def text_ink(S, text, font_path, x0, x1, cy, rng):
img = Image.new("L", (S, S), 0); d = ImageDraw.Draw(img)
size = int(S * 0.32)
while size > 6: # largest size fitting the right-hand region
f = ImageFont.truetype(font_path, size)
l, t, rr, b = d.textbbox((0, 0), text, font=f)
if rr - l <= (x1 - x0) and b - t <= S * 0.4: break
size -= 2
w, h = rr - l, b - t
d.text((x0 + (x1 - x0 - w) / 2 - l, cy - h / 2 - t), text, fill=255, font=f)
return np.asarray(img, np.float32) / 255
def distress(a, rng, S):
"""Ink bleed (blur+soft-threshold spread) and distressed edges (multiplicative noise holes)."""
nprng = np.random.default_rng(rng.randrange(2**31))
a = cv2.GaussianBlur(a, (0, 0), S / 400 * rng.uniform(0.8, 1.5))
a = np.clip(a * 1.25, 0, 1) # bleed: spreads ink slightly outward
noise = cv2.resize(nprng.random((S // 6, S // 6)).astype(np.float32), (S, S), interpolation=cv2.INTER_LINEAR)
speck = nprng.random((S, S)) > 0.04
holes = np.clip((noise - 0.12) * 4, 0, 1) * speck # worn patches + speckle dropouts
return a * holes * rng.uniform(0.85, 1.0)
def render(res, rng, fonts):
S = res * 2 # supersample then downsample for clean AA
color_name = rng.choice(list(PALETTE)); rgb = np.array(PALETTE[color_name], np.float32)
expr = rng.choice(EXPRESSIONS); text = random_text(rng)
paper = newsprint(S, rng, fonts)
cx, cy = S * rng.uniform(0.22, 0.27), S * rng.uniform(0.46, 0.54); r = S * rng.uniform(0.16, 0.19)
col_a = distress(halftone(S, cx, cy, r * 0.97, rng), rng, S)
blk_a = distress(np.maximum(face_ink(S, cx, cy, r, expr, rng),
text_ink(S, text, rng.choice(fonts), S*0.48, S*0.96, S*rng.uniform(0.46, 0.54), rng)), rng, S)
# subtractive ink model: paper * (1 - a*(1 - ink/255))
img = paper * (1 - col_a[..., None] * (1 - rgb / 255))
img = img * (1 - blk_a[..., None] * (1 - np.array([25, 22, 20]) / 255))
img = cv2.resize(np.clip(img, 0, 255).astype(np.uint8), (res, res), interpolation=cv2.INTER_AREA)
meta = {"color": {"name": color_name, "rgb": [round(c / 255, 4) for c in PALETTE[color_name]]},
"expression": expr, "text": text,
"caption": f"a {color_name} {expr} face with '{text}' next to it"}
return Image.fromarray(img), meta
def _work(job):
i, seed, res, out, fonts = job
rng = random.Random(seed * 1_000_003 + i) # deterministic per-sample
img, meta = render(res, rng, fonts)
rel = f"images/{i:06d}.png"; img.save(os.path.join(out, rel))
return {"image_path": rel, **meta}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--num_samples", type=int, default=1000)
ap.add_argument("--output_dir", default="data")
ap.add_argument("--resolution", type=int, default=128, choices=[128, 256])
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--workers", type=int, default=os.cpu_count())
a = ap.parse_args()
fonts = ensure_fonts(); print("fonts:", [os.path.basename(f) for f in fonts])
os.makedirs(os.path.join(a.output_dir, "images"), exist_ok=True)
jobs = [(i, a.seed, a.resolution, a.output_dir, fonts) for i in range(a.num_samples)]
with open(os.path.join(a.output_dir, "metadata.jsonl"), "w") as fo, Pool(max(1, a.workers)) as pool:
for k, rec in enumerate(pool.imap(_work, jobs, chunksize=16)): # imap keeps order
fo.write(json.dumps(rec) + "\n")
if (k + 1) % 1000 == 0: print(f"{k+1}/{a.num_samples}", flush=True)
print(f"wrote {a.num_samples} samples to {a.output_dir}")
if __name__ == "__main__":
main()