runic-reader-2 / code /synth.py
kesha-humonen's picture
Add files using upload-large-folder tool
ed46c5d verified
Raw History Blame Contribute Delete
8.24 kB
"""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()