Compare commits
2 Commits
490ef4291a
...
894c17291d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
894c17291d | ||
|
|
ef8bbca9a4 |
File diff suppressed because one or more lines are too long
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