"""Procedural synthetic photographs of carved runic inscriptions. Lexicon: words of the *train* split only (frequency >= 2), sampled with temperature 0.7 into 1-5 word phrases. Each transliteration is mapped to runes with a fixed table (Younger Futhark long-branch core, dotted medieval runes and Elder Futhark signs for the remaining letters), rendered with one of three runic fonts, and composited onto a noise-based stone texture as an incised groove (height map + directional light), optionally painted, laid out straight, vertical, in a framed band or along a curve, then degraded (lichen, blur, perspective, exposure, JPEG). The label is the transliteration string itself. """ import argparse import io import json import math import os import random from collections import Counter import numpy as np from PIL import Image, ImageDraw, ImageFilter, ImageFont RUNE = { "f": "ᚠ", "u": "ᚢ", "þ": "ᚦ", "o": "ᚬ", "ą": "ᚭ", "r": "ᚱ", "k": "ᚴ", "h": "ᚼ", "n": "ᚾ", "i": "ᛁ", "a": "ᛅ", "s": "ᛋ", "t": "ᛏ", "b": "ᛒ", "m": "ᛘ", "l": "ᛚ", "R": "ᛦ", "e": "ᛂ", "g": "ᚵ", "d": "ᛑ", "p": "ᛔ", "y": "ᚤ", "c": "ᛍ", "v": "ᚡ", "z": "ᛎ", "x": "ᛪ", "q": "ᛩ", "w": "ᚹ", "j": "ᛃ", "ð": "ᚧ", "ï": "ᛇ", "ŋ": "ᛜ", "æ": "ᛆ", "ø": "ᚯ", "ö": "ᚯ", "å": "ᚮ", "ä": "ᛅ", } SEPARATORS = ["᛫", "᛬", "×", ":", "᛭", " "] FONT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts") FONTS = ["NotoSansRunic-Regular.ttf", "Junicode-Regular.ttf", "Junicode-Bold.ttf"] def lexicon(corpus_path): words = Counter() for l in open(corpus_path): r = json.loads(l) if r["split"] == "train": words.update(w for w in r["norm"].split() if 2 <= len(w) <= 20) return {w: c for w, c in words.items() if c >= 2} def sample_phrase(rng, words, weights): n = rng.choices([1, 2, 3, 4, 5], weights=[2, 3, 3, 2, 1])[0] return " ".join(rng.choices(words, weights=weights, k=n)) def smooth_noise(rng, h, w, octaves=5): nrng = np.random.default_rng(rng.randint(0, 2**31)) out = np.zeros((h, w), np.float32) amp = 1.0 for o in range(octaves): sh, sw = max(2, h // (2 ** (octaves - o))), max(2, w // (2 ** (octaves - o))) base = nrng.standard_normal((sh, sw)).astype(np.float32) up = np.array(Image.fromarray(base).resize((w, h), Image.BICUBIC)) out += amp * up amp *= 0.55 out -= out.min() return out / (out.max() + 1e-6) def stone(rng, h, w): base = np.array([rng.uniform(90, 175)] * 3, np.float32) tint = np.array([rng.uniform(-18, 22), rng.uniform(-12, 10), rng.uniform(-25, 8)], np.float32) n1 = smooth_noise(rng, h, w) nrng = np.random.default_rng(rng.randint(0, 2**31)) speck = (nrng.random((h, w)) < rng.uniform(0.01, 0.08)).astype(np.float32) speck = np.array(Image.fromarray((speck * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(0.8)), np.float32) / 255.0 img = base + tint + (n1[..., None] - 0.5) * rng.uniform(40, 110) img += (speck[..., None] * rng.uniform(-60, 60)) return img def text_mask(rng, runes, W, H): font = ImageFont.truetype(os.path.join(FONT_DIR, rng.choice(FONTS)), 10) size = int(H * rng.uniform(0.12, 0.3)) for _ in range(40): font = font.font_variant(size=size) bbox = font.getbbox(runes) if bbox[2] - bbox[0] <= W * 0.92: break size = int(size * 0.9) tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] m = Image.new("L", (W, H), 0) d = ImageDraw.Draw(m) x = (W - tw) // 2 - bbox[0] + rng.randint(-W // 20, W // 20) y = (H - th) // 2 - bbox[1] + rng.randint(-H // 12, H // 12) stroke = rng.choice([0, 0, 1, 2]) d.text((x, y), runes, fill=255, font=font, stroke_width=stroke, stroke_fill=255) band = rng.random() < 0.45 if band: pad = int(th * rng.uniform(0.25, 0.5)) lw = max(2, size // 12) d.line([(x + bbox[0] - pad, y + bbox[1] - pad), (x + bbox[2] + pad, y + bbox[1] - pad)], fill=255, width=lw) d.line([(x + bbox[0] - pad, y + bbox[3] + pad), (x + bbox[2] + pad, y + bbox[3] + pad)], fill=255, width=lw) return np.array(m, np.float32) / 255.0 def bend(mask, rng): h, w = mask.shape amp = rng.uniform(0.05, 0.18) * h freq = rng.uniform(0.5, 1.5) ys, xs = np.mgrid[0:h, 0:w] shift = amp * np.sin(2 * math.pi * freq * xs / w + rng.uniform(0, 6.28)) src_y = np.clip((ys - shift).astype(np.int32), 0, h - 1) return mask[src_y, xs] def render(rng, translit, out_long=768): runes = "".join(RUNE.get(ch, "") if ch != " " else "\x00" for ch in translit) sep = rng.choice(SEPARATORS) runes = runes.replace("\x00", f" {sep} " if sep != " " else " ").replace(" ", " ") layout = rng.choices(["h", "v", "curve"], weights=[5, 3, 2])[0] W, H = 1100, rng.choice([320, 420, 520]) mask = text_mask(rng, runes, W, H) if layout == "curve": mask = bend(mask, rng) depth = np.array(Image.fromarray((mask * 255).astype(np.uint8)).filter( ImageFilter.GaussianBlur(rng.uniform(1.0, 3.0))), np.float32) / 255.0 gy, gx = np.gradient(-depth) ang = rng.uniform(0, 2 * math.pi) light = (gx * math.cos(ang) + gy * math.sin(ang)) * rng.uniform(250, 600) tex = stone(rng, H, W) groove = depth[..., None] * rng.uniform(20, 70) img = tex - groove + light[..., None] if rng.random() < 0.35: # painted runes, as on many restored runestones paint = np.array(rng.choice([(150, 30, 30), (170, 40, 35), (30, 30, 30), (200, 200, 190)]), np.float32) a = np.clip(mask * rng.uniform(0.6, 0.95), 0, 1)[..., None] img = img * (1 - a) + paint * a if rng.random() < 0.5: # lichen / moss blotches blot = smooth_noise(rng, H, W, 4) > rng.uniform(0.72, 0.85) col = np.array(rng.choice([(170, 170, 120), (90, 110, 60), (200, 190, 150)]), np.float32) img = np.where(blot[..., None], img * 0.4 + col * 0.6, img) img = np.clip(img, 0, 255).astype(np.uint8) im = Image.fromarray(img) if layout == "v": im = im.rotate(rng.choice([90, -90]), expand=True) fill = tuple(int(v) for v in img.mean((0, 1))) im = im.rotate(rng.uniform(-6, 6), expand=True, fillcolor=fill) w, h = im.size dx, dy = rng.uniform(-0.06, 0.06) * w, rng.uniform(-0.06, 0.06) * h coeffs = (1 + rng.uniform(-0.05, 0.05), rng.uniform(-0.05, 0.05), dx, rng.uniform(-0.05, 0.05), 1 + rng.uniform(-0.05, 0.05), dy, rng.uniform(-1e-4, 1e-4), rng.uniform(-1e-4, 1e-4)) im = im.transform(im.size, Image.PERSPECTIVE, coeffs, Image.BICUBIC, fillcolor=fill) if rng.random() < 0.6: im = im.filter(ImageFilter.GaussianBlur(rng.uniform(0.3, 1.6))) arr = np.array(im, np.float32) arr = arr * rng.uniform(0.6, 1.3) + rng.uniform(-30, 30) arr += np.random.default_rng(rng.randint(0, 2**31)).normal(0, rng.uniform(2, 10), arr.shape) im = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8)) im.thumbnail((out_long, out_long)) buf = io.BytesIO() im.save(buf, "JPEG", quality=rng.randint(55, 92)) return Image.open(buf).convert("RGB") def main(): ap = argparse.ArgumentParser() ap.add_argument("--corpus", required=True) ap.add_argument("--out_dir", required=True) ap.add_argument("--n", type=int, default=6000) ap.add_argument("--seed", type=int, default=13) ap.add_argument("--start", type=int, default=0) args = ap.parse_args() rng = random.Random(args.seed + args.start) lex = lexicon(args.corpus) words = sorted(lex) weights = [lex[w] ** 0.7 for w in words] os.makedirs(os.path.join(args.out_dir, "img"), exist_ok=True) with open(os.path.join(args.out_dir, f"labels_{args.start}.jsonl"), "w") as f: for i in range(args.start, args.start + args.n): t = sample_phrase(rng, words, weights) name = f"img/syn{i:06d}.jpg" render(rng, t).save(os.path.join(args.out_dir, name), quality=90) f.write(json.dumps({"id": f"syn{i:06d}", "image": name, "target": t}, ensure_ascii=False) + "\n") print("lexicon", len(words), "wrote", args.n) if __name__ == "__main__": main()