Lilly-api / app /truecase.py
asdfasdfqrqwer's picture
Lilly: Bosnian to English, typed, spoken or photographed
0e499ed
Raw History Blame Contribute Delete
10.2 kB
#!/usr/bin/env python3
"""Put shouted source text back into sentence case before it is translated.
Signs are written in capitals -- "ZABRANJEN ULAZ", "DANGER HIGH VOLTAGE KEEP
OUT" -- and this translator was trained on ordinary case, so an all-caps word
splits into sub-word pieces it barely knows and the answer comes out wrong.
Measured on the served build, ALL-CAPS source costs it 25.5 chrF2 against the
same sentence in ordinary case, and a crude restorer gives 23.1 of it back; it
is the largest single win in
docs/REPORT-what-would-raise-the-numbers-2026-09-13.md (section 1) and needs no
GPU. This module is that restorer.
It is staged, not shipped. The flag LILLY_TRUECASE turns it on (default off),
because the report's 25.5 chrF2 is a diagnostic measured on uppercased FLORES
sentences: real signs are shorter, and the honest bar for the product is a score
on photographs. That needs its own pre-registration and a photograph-bar
measurement first. Until then the app behaves exactly as it does today unless
the flag is set.
Two entry points, one restorer. restore_sentence_case rewrites a whole string
and is what the FLORES diagnostic measures. restore_photo_text is the one the
product runs: it lives in app.lilly.translate_photo, not in the shared
Engine.translate, so only the camera path can recase and typed/chat translation
is never touched; and it works line by line, rewriting a line only when it is
both shouted and in the language being translated from, so a foreign caption or
a brand on the same sign is left alone.
"""
import os
import re
# How loud is loud enough to act on. Two guards, both needed:
#
# * at least MIN_LETTERS letters. A four-letter all-caps token is as likely to
# be an acronym ("NASA", "USA") as a sign, and lowercasing it would corrupt
# ordinary text; a sign worth recasing is usually longer than eight letters.
# * at least UPPER_RATIO of the letters uppercase. A title, a name, an
# ordinary sentence all sit far below this, so they pass through
# byte-for-byte. The 0.8 rather than 1.0 tolerates the stray lowercase
# letter an OCR pass leaves in an otherwise shouted line, which is exactly
# the input this runs on.
MIN_LETTERS = 8
UPPER_RATIO = 0.8
# Names are the one thing a blanket lowercase destroys, and the report measured
# the residual loss after a crude restorer as "mostly proper nouns", so keeping
# them is what closes the last of the 25.5. Bosnian inflects a name heavily --
# Bosna, Bosne, Bosni, bosanski -- and each stem is listed with only the endings
# that keep it a *name*. That distinction is the whole point: -a/-e/-i/-u/-om
# are the noun cases, while -ski/-ska in the middle of a sentence is the derived
# adjective, which Bosnian writes lowercase ("govorim srpski", "bosanski jezik")
# even though a name is not. Matching the bare stem would capitalise those too;
# matching stem-plus-name-ending does not. Deliberately short and expandable; a
# name that is not here is lowercased like any other word. A middle dot stands
# for the bare stem ("mostar"), because an empty string is easy to miss.
#
# Days and months are deliberately absent: standard Bosnian/Serbian/Croatian
# writes them lowercase ("ponedjeljak", "januar"), so capitalising them would be
# the mistake, not the fix.
PROPER_FORMS = {
"sarajev": ("o", "a", "u", "om"),
"bosn": ("a", "e", "i", "u", "om"),
"hercegov": ("ina", "ine", "ini", "inu", "inom"),
"beograd": ("", "a", "u", "om"),
"mostar": ("", "a", "u", "om"),
"tuzl": ("a", "e", "i", "u", "om"),
"zenic": ("a", "e", "i", "u", "om"),
# Hrvatska/Srpska are the country names; hrvatski/srpski are the languages.
"hrvat": ("", "a", "e", "i", "skoj", "sku", "skom", "skoga"),
"srbij": ("a", "e", "i", "u", "om"),
"srpsk": ("a", "e", "oj", "u", "om", "oga"),
"jugoslavij": ("a", "e", "i", "u", "om"),
"evrop": ("a", "e", "i", "u", "om"),
"europ": ("a", "e", "i", "u", "om"),
}
# Words (letters only, so a diacritic stays inside its word), numbers, runs of
# punctuation, and whitespace. Splitting this way is what lets the restorer
# lowercase word by word instead of running str.lower() over the string, which
# would also flatten the capital that marks a sentence start.
_TOKEN = re.compile(r"[^\W\d_]+|\d+|[^\w\s]+|\s+", re.UNICODE)
def enabled() -> bool:
"""Whether LILLY_TRUECASE turns the recaser on, on app/ocr.py's pattern.
Unset or "0" means off, which is the shipped default and the only value any
serving path gets unless somebody sets it. An unrecognised value raises
rather than guessing, the same way LILLY_READER does.
"""
raw = os.environ.get("LILLY_TRUECASE", "").strip().lower()
if raw in ("", "0", "false", "no", "off"):
return False
if raw not in ("1", "true", "yes", "on"):
raise RuntimeError(f"LILLY_TRUECASE={raw!r}; want 1 or 0")
return True
def is_predominantly_upper(text: str) -> bool:
"""True when the text is shouted (a sign) rather than merely capitalised.
Only letters count: digits, punctuation and spacing carry no case and would
otherwise make a blank form look calm. Text with too few letters, or text
whose letters are not mostly uppercase, is left alone -- that is what keeps
ordinary and mixed-case input byte-for-byte unchanged.
"""
letters = [c for c in text if c.isalpha()]
if len(letters) < MIN_LETTERS:
return False
upper = sum(1 for c in letters if c.isupper())
return upper / len(letters) >= UPPER_RATIO
def _capitalise(word: str) -> str:
"""First letter up, the rest as given -- never str.capitalize().
str.capitalize() lowercases everything but the first character, which is
fine until a word is a proper noun we meant to keep. Uppercasing one
character leaves č ć đ š ž untouched wherever they are not first.
"""
return word[:1].upper() + word[1:]
def _is_proper(lower: str) -> bool:
"""True when the lowercased word is a listed name in one of its own cases."""
for stem, endings in PROPER_FORMS.items():
if lower.startswith(stem) and lower[len(stem):] in endings:
return True
return False
def restore_sentence_case(text: str) -> tuple:
"""Lowercase shouted text and capitalise each sentence. -> (text, changed).
Returns the input untouched -- and changed=False -- whenever the text is not
predominantly uppercase, so a caller can apply this unconditionally and only
an actually shouted string is rewritten. Pure: no model, no I/O.
"""
if not is_predominantly_upper(text):
return text, False
out = []
at_start = True # a sentence begins at the next word
prev_number = False # the token just before this one was a number
for token in _TOKEN.findall(text):
if token.isspace():
out.append(token)
if "\n" in token:
# A line break is a sentence boundary here, the same as in the
# translator's own splitter: a sign's lines are separate notices.
at_start = True
prev_number = False
elif token[0].isdigit():
out.append(token)
at_start = False
prev_number = True
elif token[0].isalpha():
lower = token.lower()
if at_start or _is_proper(lower):
out.append(_capitalise(lower))
else:
out.append(lower)
at_start = False
prev_number = False
else:
out.append(token)
# "5. maja 1990. godine" -- a period right after a digit is an
# ordinal, not a full stop, so the word after it must stay lowercase.
# In all-caps text there is no lowercase word to read the boundary
# from, so the safe reading is the ordinal one; a real sentence that
# ends in a number keeps its following word lowercase, which is the
# small residual this restorer does not attempt (the report's
# measurement has the same floor).
if token[-1] in ".!?" and not (token == "." and prev_number):
at_start = True
prev_number = False
restored = "".join(out)
return restored, restored != text
def restore_photo_text(text: str, source_lang: str = "bs") -> tuple:
"""Recase a photograph's OCR text line by line. -> (text, changed).
Truecasing helps the translator read a shouted sign in the language it is
translating *from*. A photograph often carries a second language too -- a
Bosnian plaque beside an English caption -- and recasing that does not help
the translation, so a line is rewritten only when it is both predominantly
upper-case and detected as `source_lang`; a line the classifier reads as the
other language, and a short token under the shout threshold, are returned
byte-for-byte. This is a heuristic, not named-entity recognition: it protects
a *foreign-language* line, not names inside a source-language one. An
in-language proper noun outside the small PROPER_FORMS list is still recased
and can then be translated literally (ŠIROKI BRIJEG -> "Široki brijeg" ->
"Wide hill") -- a measured residual, not a solved case.
Line by line, on the newlines the reader already puts between regions, so a
misread on one line cannot pull the next out of case. This is the
photograph-path restorer app.lilly.translate_photo runs behind
LILLY_TRUECASE; restore_sentence_case above is the whole-string one the
FLORES diagnostic uses. The language check is app.detect's classifier, which
reads case-folded n-grams, so a shouted line is classified by its letters
exactly as an ordinary one would be.
"""
from app.detect import detect_language
out, changed = [], False
for line in text.split("\n"):
if is_predominantly_upper(line) and detect_language(line) == source_lang:
restored, line_changed = restore_sentence_case(line)
out.append(restored)
changed = changed or line_changed
else:
out.append(line)
return "\n".join(out), changed