File size: 9,272 Bytes
7ed86c3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | """Project macron / scansion annotations onto Stoicheia's letter planes.
The backbone codec (data/normalize.py) turns text into per-LETTER planes: every Greek
letter is one position; spaces, punctuation and editorial marks are folded into the
boundary/punct channels or stripped. Both annotation formats mark *letters*:
macronized text `_` (long) / `^` (short) written after an ambiguous dichronon,
e.g. "βα^ρύκτυ^πος"; combining macron/breve are accepted too
bracketed verse [heavy] {light} syllable spans, weight belonging to the last
letter of the span, the line-final syllable being verse-end
(brevis in longo), e.g. "[ὦ] [παῖ] {τέ}[λος] ..."
So a label is "letter ordinal -> class". We walk the annotated text with the SAME
character-kind LUT the codec uses, counting letters exactly as normalize_record will
count them on the stripped text — projection is alignment-exact by construction and
asserted at encode time.
Label conventions follow an existing macronization project (its Norma scorer and
scanner corpus are reused verbatim):
macron: 0 = long, 1 = short
scan: 0 = none, 1 = heavy syllable ends here, 2 = light ends, 3 = verse ends
"""
from __future__ import annotations
import re
import unicodedata
import numpy as np
from meter.backbone import ALPHABET # noqa: F401 (ensures STOICHEIA_ROOT is on sys.path)
from data.normalize import _KIND, K_LETTER, LETTER_IDS, unpack_dia
MAC_LONG, MAC_SHORT = 0, 1
SCAN_O, SCAN_HEAVY, SCAN_LIGHT, SCAN_VERSE = 0, 1, 2, 3
IGNORE = -100
# annotation characters (never part of the codec's letter set)
_LONG_MARKS = {"_", "̄"} # ASCII underscore, combining macron
_SHORT_MARKS = {"^", "̆"} # ASCII caret, combining breve
_ALL_MARKS = _LONG_MARKS | _SHORT_MARKS
_A, _E, _H, _I, _O, _Y, _W = (LETTER_IDS[c] for c in "αεηιουω")
DICHRONA_IDS = np.array([_A, _I, _Y])
VOWEL_IDS = np.array([_A, _E, _H, _I, _O, _Y, _W])
# (first, second) letter-id pairs that form a diphthong
DIPHTHONGS = {(_A, _I), (_A, _Y), (_E, _I), (_E, _Y), (_H, _Y),
(_O, _I), (_O, _Y), (_Y, _I), (_W, _Y)}
def _is_letter(ch: str) -> bool:
"""Does this (possibly precomposed) character contribute one codec letter?"""
cp = ord(unicodedata.normalize("NFD", ch)[0])
return cp < len(_KIND) and _KIND[cp] == K_LETTER
def parse_macron_line(marked: str):
"""Annotated line -> (plain_text, {letter_ordinal: MAC_LONG|MAC_SHORT}).
plain_text is the line with all length marks removed (NFC); letter ordinals
count codec letters and therefore index normalize_record's planes directly.
"""
nfd = unicodedata.normalize("NFD", marked)
kept, labels = [], {}
ordinal = -1
for ch in nfd:
if ch in _ALL_MARKS:
if ordinal >= 0:
labels[ordinal] = MAC_LONG if ch in _LONG_MARKS else MAC_SHORT
continue
cp = ord(ch)
if cp < len(_KIND) and _KIND[cp] == K_LETTER:
ordinal += 1
kept.append(ch)
return unicodedata.normalize("NFC", "".join(kept)), labels
_SYL = re.compile(r"\[([^\]]*)\]|\{([^}]*)\}")
def parse_scan_line(bracketed: str):
"""Bracketed verse -> (plain_text, {letter_ordinal: scan class}).
The syllable weight sits on the LAST letter of the span (the old corpus puts it
on the last non-space character, which can be an apostrophe — we take the last
codec letter instead, which is what the planes can address). The final labeled
letter of the line becomes SCAN_VERSE. Returns None for lines with no syllables.
"""
plain_parts, labels = [], {}
ordinal = -1
pos = 0
last_labeled = None
def advance(text):
nonlocal ordinal
last = None
for ch in text:
if _is_letter(ch):
ordinal += 1
last = ordinal
plain_parts.append(text)
return last
for m in _SYL.finditer(bracketed):
advance(bracketed[pos:m.start()])
text, weight = ((m.group(1), SCAN_HEAVY) if m.group(1) is not None
else (m.group(2), SCAN_LIGHT))
last = advance(text)
if last is not None:
labels[last] = weight
last_labeled = last
pos = m.end()
advance(bracketed[pos:])
if last_labeled is None:
return None
labels[last_labeled] = SCAN_VERSE
return unicodedata.normalize("NFC", "".join(plain_parts)), labels
def ambiguous_mask(chars: np.ndarray, boundary: np.ndarray, dia: np.ndarray):
"""Which plane positions are ambiguous dichrona (the macronizer's domain)?
A position is ambiguous iff it is a base α/ι/υ that does not carry circumflex or
iota subscript and is not part of a diphthong; diaeresis on the second vowel
breaks the diphthong, and pairs never span a word boundary. Same rule as the old
project's `markable()` (macronize_corpus.py), computed on the planes.
"""
n = len(chars)
d = np.asarray(dia, dtype=np.int64)
acc, _br, iota, diaer = unpack_dia(d.copy())
is_dich = np.isin(chars, DICHRONA_IDS)
out = is_dich & (acc != 3) & (iota == 0)
if n > 1:
pair = np.zeros(n - 1, dtype=bool)
for f, s in DIPHTHONGS:
pair |= (chars[:-1] == f) & (chars[1:] == s)
pair &= boundary[:-1] == 0 # no word boundary inside a diphthong
# second element of a diphthong (unless it carries diaeresis)
out[1:] &= ~(pair & (diaer[1:] == 0))
# first element of a diphthong (unless the second carries diaeresis)
out[:-1] &= ~(pair & (diaer[1:] == 0))
return out
def merge_vowelless_syllables(chars: np.ndarray, scan_labels: np.ndarray) -> np.ndarray:
"""A predicted "syllable" span with no vowel isn't a syllable -- it's a boundary
placed one letter early, typically at the first of a geminate consonant pair
(e.g. predicted "{λε}[ν]" for what should be one closed syllable "[λεν]").
Merge any such span into the PRECEDING one by dropping the earlier boundary,
keeping the vowel-less span's OWN weight label: that label (usually SCAN_HEAVY,
since it's a closing consonant) is normally already correct for the merged
syllable -- only the boundary was misplaced. A vowel-less span at the very
start of the line (no preceding syllable to merge into) is left as-is."""
out = np.asarray(scan_labels).copy()
is_vowel = np.isin(chars, VOWEL_IDS)
kept = []
start = 0
for i in range(len(out)):
if out[i] == SCAN_O:
continue
if not is_vowel[start:i + 1].any() and kept:
out[kept.pop()] = SCAN_O
kept.append(i)
start = i + 1
return out
def enforce_circumflex_heavy(dia: np.ndarray, scan_labels: np.ndarray) -> np.ndarray:
"""Circumflex marks a categorically long vowel, and a syllable containing one is
always heavy -- a fixed rule of Greek prosody, not something the per-letter scan
classifier can get wrong in principle, only in practice. Walk each predicted
syllable span (consecutive letters up to and including the next non-SCAN_O
label); if it contains a circumflexed letter and the model called it
SCAN_LIGHT, flip that span's label to SCAN_HEAVY. SCAN_VERSE is left alone (it
already renders as a heavy-looking bracket); boundary PLACEMENT is untouched --
this only corrects a syllable's weight, never whether one was predicted there."""
d = np.asarray(dia, dtype=np.int64)
acc, _br, _iota, _diaer = unpack_dia(d.copy())
has_circ = acc == 3
out = np.asarray(scan_labels).copy()
start = 0
for i in range(len(out)):
if out[i] != SCAN_O:
if out[i] == SCAN_LIGHT and has_circ[start:i + 1].any():
out[i] = SCAN_HEAVY
start = i + 1
return out
def insert_marks(plain: str, labels: dict[int, int]) -> str:
"""Write `_`/`^` after the letters given by {letter_ordinal: MAC_*} (production
output format, identical to the old project's)."""
nfc = unicodedata.normalize("NFC", plain)
out = []
ordinal = -1
pending = None
for ch in nfc:
if pending is not None and not unicodedata.category(ch).startswith("M"):
out.append(pending)
pending = None
out.append(ch)
if _is_letter(ch):
ordinal += 1
if ordinal in labels:
pending = "_" if labels[ordinal] == MAC_LONG else "^"
if pending is not None:
out.append(pending)
return "".join(out)
def bracketize(plain: str, labels: dict[int, int]) -> str:
"""Render per-letter scan labels back into [heavy]{light} spans (verse-end span
is emitted as heavy, matching brevis in longo display in the old corpus)."""
nfc = unicodedata.normalize("NFC", plain)
out, cur = [], []
ordinal = -1
for ch in nfc:
cur.append(ch)
if _is_letter(ch):
ordinal += 1
lab = labels.get(ordinal, SCAN_O)
if lab != SCAN_O:
o, c = ("{", "}") if lab == SCAN_LIGHT else ("[", "]")
out.append(o + "".join(cur) + c)
cur = []
if cur:
out.append("".join(cur))
return "".join(out)
|