Download app/truecase.py from Safak11/Lilly-api: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/spaces/Safak11/Lilly-api/resolve/main/app/truecase.py
- Command line
-
hf download hf://spaces/Safak11/Lilly-api/app/truecase.py
-
curl -L -o truecase.py https://huggingface.co/spaces/Safak11/Lilly-api/resolve/main/app/truecase.py
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 | |