imperial-typewriter v1: compose imperial-tongue words from glyph PNGs
Small Python tool (Pillow) that reads glyph assets from the closed studio repo and composes Latin words as single PNGs for VLM-decoder training data generation. - typewriter.py DAFIT # single word - typewriter.py --csv words.csv # batch from wordlist - --polarity positive|negative|both (default both) - glyph-cell-tight composition (no gap), matches in-game inscription layout - auto-uppercases input — the imperial machine always screams Sits in the open repo (tools/imperial-typewriter/); reads from the closed studio repo (default /home/dafit/nimmerverse/studio.nimmerworld.eachpath.local). Clean cut: assets in studio, code that consumes them in nimmerworld. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
tools/imperial-typewriter/.gitignore
vendored
Normal file
1
tools/imperial-typewriter/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
output/
|
||||
6
tools/imperial-typewriter/test-words.csv
Normal file
6
tools/imperial-typewriter/test-words.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
# test wordlist for imperial-typewriter — comments and blank lines are skipped
|
||||
DAFIT
|
||||
MOIRA
|
||||
NIMMER
|
||||
EROS
|
||||
SOPHROSYNE
|
||||
|
190
tools/imperial-typewriter/typewriter.py
Normal file
190
tools/imperial-typewriter/typewriter.py
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Imperial typewriter — compose imperial-tongue words from glyph PNGs.
|
||||
|
||||
Reads glyph assets from the closed studio repo; writes composed word PNGs
|
||||
to an output directory for VLM-decoder training data generation.
|
||||
|
||||
Composition is glyph-cell-tight (no gap between glyphs) — matches the
|
||||
in-game inscription-typing layout. The imperial machine always screams:
|
||||
input is auto-uppercased before composition.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
DEFAULT_STUDIO_ROOT = "/home/dafit/nimmerverse/studio.nimmerworld.eachpath.local"
|
||||
DEFAULT_GENERATION = "03"
|
||||
DEFAULT_RESOLUTION = "512x1024"
|
||||
|
||||
VOWELS = set("AEIOUY")
|
||||
CONSONANTS = set("BCDFGHJKLMNPQRSTVWXZ")
|
||||
DIGITS = set("0123456789")
|
||||
|
||||
CATEGORY_INFO = {
|
||||
"vowel": ("vowels", "vowel"),
|
||||
"consonant": ("consonant", "consonant"),
|
||||
"number": ("numbers", "number"),
|
||||
}
|
||||
|
||||
|
||||
def category_for(char: str) -> tuple[str, str]:
|
||||
"""Return (category-dir-name, file-prefix) for a single character."""
|
||||
if char in VOWELS:
|
||||
return CATEGORY_INFO["vowel"]
|
||||
if char in CONSONANTS:
|
||||
return CATEGORY_INFO["consonant"]
|
||||
if char in DIGITS:
|
||||
return CATEGORY_INFO["number"]
|
||||
raise ValueError(f"No imperial-tongue glyph for character: {char!r}")
|
||||
|
||||
|
||||
def glyph_path(
|
||||
char: str,
|
||||
polarity: str,
|
||||
studio_root: str,
|
||||
generation: str = DEFAULT_GENERATION,
|
||||
resolution: str = DEFAULT_RESOLUTION,
|
||||
) -> Path:
|
||||
cat_dir, prefix = category_for(char)
|
||||
suffix = "_neg" if polarity == "negative" else ""
|
||||
filename = f"{prefix}_{char}_{resolution}{suffix}.png"
|
||||
return (
|
||||
Path(studio_root)
|
||||
/ "imperial-cult"
|
||||
/ "assets"
|
||||
/ "imperial-tongue"
|
||||
/ cat_dir
|
||||
/ generation
|
||||
/ f"{prefix}_{char}"
|
||||
/ filename
|
||||
)
|
||||
|
||||
|
||||
def compose_word(
|
||||
word: str,
|
||||
polarity: str,
|
||||
studio_root: str,
|
||||
generation: str = DEFAULT_GENERATION,
|
||||
resolution: str = DEFAULT_RESOLUTION,
|
||||
) -> Image.Image:
|
||||
glyphs = []
|
||||
for char in word:
|
||||
path = glyph_path(char, polarity, studio_root, generation, resolution)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Missing glyph asset: {path}")
|
||||
glyphs.append(Image.open(path).convert("RGBA"))
|
||||
|
||||
width = sum(g.width for g in glyphs)
|
||||
height = max(g.height for g in glyphs)
|
||||
composed = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
|
||||
x = 0
|
||||
for g in glyphs:
|
||||
composed.paste(g, (x, 0), g)
|
||||
x += g.width
|
||||
|
||||
return composed
|
||||
|
||||
|
||||
def words_from_csv(path: Path) -> list[str]:
|
||||
"""Read words from a CSV file. Takes the first column of each non-empty row.
|
||||
Skips blank rows and rows whose first cell is empty or starts with '#'."""
|
||||
words = []
|
||||
with open(path, newline="") as f:
|
||||
reader = csv.reader(f)
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
cell = row[0].strip()
|
||||
if not cell or cell.startswith("#"):
|
||||
continue
|
||||
words.append(cell)
|
||||
return words
|
||||
|
||||
|
||||
def render_one(
|
||||
word: str,
|
||||
output_dir: Path,
|
||||
polarities: list[str],
|
||||
studio_root: str,
|
||||
generation: str,
|
||||
resolution: str,
|
||||
) -> None:
|
||||
word = word.upper()
|
||||
for pol in polarities:
|
||||
img = compose_word(word, pol, studio_root, generation, resolution)
|
||||
suffix = "_neg" if pol == "negative" else ""
|
||||
out_path = output_dir / f"{word}{suffix}.png"
|
||||
img.save(out_path)
|
||||
print(f"wrote {out_path} ({img.width}x{img.height})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Imperial typewriter — compose imperial-tongue words from glyph PNGs.",
|
||||
)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument(
|
||||
"word",
|
||||
nargs="?",
|
||||
help="A single Latin word to compose. Auto-uppercased (imperial machine always screams).",
|
||||
)
|
||||
source.add_argument(
|
||||
"--csv",
|
||||
type=Path,
|
||||
help="Path to a CSV/wordlist file. First column of each non-empty, non-'#'-prefixed row is used as a word.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path("./output"),
|
||||
help="Output directory (created if missing). Default: ./output",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--polarity",
|
||||
choices=["positive", "negative", "both"],
|
||||
default="both",
|
||||
help="Which polarity to render. Default: both",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--studio-root",
|
||||
default=DEFAULT_STUDIO_ROOT,
|
||||
help=f"Path to studio repo root. Default: {DEFAULT_STUDIO_ROOT}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generation",
|
||||
default=DEFAULT_GENERATION,
|
||||
help=f"Resolution generation subdir. Default: {DEFAULT_GENERATION}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resolution",
|
||||
default=DEFAULT_RESOLUTION,
|
||||
help=f"Glyph resolution. Default: {DEFAULT_RESOLUTION}",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
polarities = ["positive", "negative"] if args.polarity == "both" else [args.polarity]
|
||||
|
||||
if args.csv:
|
||||
words = words_from_csv(args.csv)
|
||||
if not words:
|
||||
print(f"no words found in {args.csv}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"composing {len(words)} word(s) from {args.csv}")
|
||||
for word in words:
|
||||
try:
|
||||
render_one(word, args.output, polarities, args.studio_root, args.generation, args.resolution)
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
print(f" skip {word!r}: {e}", file=sys.stderr)
|
||||
else:
|
||||
render_one(args.word, args.output, polarities, args.studio_root, args.generation, args.resolution)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user