Spaces:
Running
Running
Update backend/lens_core.py
Browse files- backend/lens_core.py +444 -10
backend/lens_core.py
CHANGED
|
@@ -119,12 +119,226 @@ UI_LANGUAGES = [
|
|
| 119 |
{"code": "ja", "name": "Japanese"},
|
| 120 |
{"code": "ko", "name": "Korean"},
|
| 121 |
{"code": "zh-CN", "name": "Chinese (Simplified)"},
|
|
|
|
| 122 |
{"code": "vi", "name": "Vietnamese"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
{"code": "es", "name": "Spanish"},
|
| 124 |
-
{"code": "de", "name": "German"},
|
| 125 |
{"code": "fr", "name": "French"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
]
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
AI_PROVIDER_DEFAULTS = {
|
| 129 |
"gemini": {
|
| 130 |
"model": "gemini-2.5-flash",
|
|
@@ -220,6 +434,13 @@ AI_LANG_STYLE = {
|
|
| 220 |
"Choose 丁寧語/タメ口 to match context; keep emotion and emphasis.\n"
|
| 221 |
"Keep proper nouns consistent; keep SFX natural in Japanese."
|
| 222 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
"default": (
|
| 224 |
"Write natural manga dialogue in the target language: concise, spoken, faithful to meaning and tone."
|
| 225 |
),
|
|
@@ -284,14 +505,31 @@ def _resolve_model(provider: str, model: str) -> str:
|
|
| 284 |
|
| 285 |
def _normalize_lang(lang: str) -> str:
|
| 286 |
t = (lang or "").strip().lower()
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
if len(t) >= 2:
|
| 296 |
return t[:2]
|
| 297 |
return t
|
|
@@ -2798,6 +3036,194 @@ def _line_metrics_px(text: str, thai_path: str, latin_path: str, size: int):
|
|
| 2798 |
baseline_to_center = -((min_t + max_b) / 2.0)
|
| 2799 |
return width, total_h, baseline_to_center
|
| 2800 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2801 |
def _item_avail_w_px(item: dict, W: int, H: int) -> float:
|
| 2802 |
b = item.get("box") or {}
|
| 2803 |
w_box = float(b.get("width") or 0.0) * float(W)
|
|
@@ -3439,6 +3865,7 @@ def draw_overlay(img, tokens, out_path, thai_path, latin_path, level_outlines=No
|
|
| 3439 |
base_rgb = img.convert("RGB")
|
| 3440 |
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
| 3441 |
draw = ImageDraw.Draw(overlay)
|
|
|
|
| 3442 |
|
| 3443 |
for ol in (level_outlines or []):
|
| 3444 |
q = ol.get("quad")
|
|
@@ -3580,7 +4007,8 @@ def draw_overlay(img, tokens, out_path, thai_path, latin_path, level_outlines=No
|
|
| 3580 |
except Exception:
|
| 3581 |
tw2, th2 = d2.textsize(text, font=font)
|
| 3582 |
|
| 3583 |
-
|
|
|
|
| 3584 |
side = min(side, int(max(W, H) * 4))
|
| 3585 |
if side < 128:
|
| 3586 |
side = 128
|
|
@@ -3669,6 +4097,9 @@ def draw_overlay(img, tokens, out_path, thai_path, latin_path, level_outlines=No
|
|
| 3669 |
|
| 3670 |
_draw_text_baseline_fallback(
|
| 3671 |
dc, origin, text, thai_path, latin_path, final_size, fill)
|
|
|
|
|
|
|
|
|
|
| 3672 |
rotated = canvas.rotate(-angle_deg, resample=Image.BICUBIC,
|
| 3673 |
expand=False, center=origin)
|
| 3674 |
paste_x = int(round(bx - origin[0]))
|
|
@@ -3677,6 +4108,9 @@ def draw_overlay(img, tokens, out_path, thai_path, latin_path, level_outlines=No
|
|
| 3677 |
else:
|
| 3678 |
_draw_text_centered_fallback(
|
| 3679 |
dc, origin, text, thai_path, latin_path, final_size, fill)
|
|
|
|
|
|
|
|
|
|
| 3680 |
rotated = canvas.rotate(-angle_deg, resample=Image.BICUBIC,
|
| 3681 |
expand=False, center=origin)
|
| 3682 |
paste_x = int(round(box_cx - origin[0]))
|
|
|
|
| 119 |
{"code": "ja", "name": "Japanese"},
|
| 120 |
{"code": "ko", "name": "Korean"},
|
| 121 |
{"code": "zh-CN", "name": "Chinese (Simplified)"},
|
| 122 |
+
{"code": "zh-TW", "name": "Chinese (Traditional)"},
|
| 123 |
{"code": "vi", "name": "Vietnamese"},
|
| 124 |
+
{"code": "id", "name": "Indonesian"},
|
| 125 |
+
{"code": "ms", "name": "Malay"},
|
| 126 |
+
{"code": "tl", "name": "Tagalog"},
|
| 127 |
+
{"code": "fil", "name": "Filipino"},
|
| 128 |
+
{"code": "hi", "name": "Hindi"},
|
| 129 |
+
{"code": "bn", "name": "Bengali"},
|
| 130 |
+
{"code": "ur", "name": "Urdu"},
|
| 131 |
+
{"code": "ta", "name": "Tamil"},
|
| 132 |
+
{"code": "te", "name": "Telugu"},
|
| 133 |
+
{"code": "ml", "name": "Malayalam"},
|
| 134 |
+
{"code": "mr", "name": "Marathi"},
|
| 135 |
+
{"code": "gu", "name": "Gujarati"},
|
| 136 |
+
{"code": "kn", "name": "Kannada"},
|
| 137 |
+
{"code": "pa", "name": "Punjabi"},
|
| 138 |
+
{"code": "ne", "name": "Nepali"},
|
| 139 |
+
{"code": "si", "name": "Sinhala"},
|
| 140 |
+
{"code": "my", "name": "Myanmar (Burmese)"},
|
| 141 |
+
{"code": "km", "name": "Khmer"},
|
| 142 |
+
{"code": "lo", "name": "Lao"},
|
| 143 |
+
{"code": "jv", "name": "Javanese"},
|
| 144 |
+
{"code": "su", "name": "Sundanese"},
|
| 145 |
{"code": "es", "name": "Spanish"},
|
|
|
|
| 146 |
{"code": "fr", "name": "French"},
|
| 147 |
+
{"code": "de", "name": "German"},
|
| 148 |
+
{"code": "it", "name": "Italian"},
|
| 149 |
+
{"code": "pt", "name": "Portuguese"},
|
| 150 |
+
{"code": "nl", "name": "Dutch"},
|
| 151 |
+
{"code": "pl", "name": "Polish"},
|
| 152 |
+
{"code": "ro", "name": "Romanian"},
|
| 153 |
+
{"code": "ru", "name": "Russian"},
|
| 154 |
+
{"code": "uk", "name": "Ukrainian"},
|
| 155 |
+
{"code": "cs", "name": "Czech"},
|
| 156 |
+
{"code": "sk", "name": "Slovak"},
|
| 157 |
+
{"code": "sl", "name": "Slovenian"},
|
| 158 |
+
{"code": "hr", "name": "Croatian"},
|
| 159 |
+
{"code": "sr", "name": "Serbian"},
|
| 160 |
+
{"code": "bs", "name": "Bosnian"},
|
| 161 |
+
{"code": "bg", "name": "Bulgarian"},
|
| 162 |
+
{"code": "mk", "name": "Macedonian"},
|
| 163 |
+
{"code": "el", "name": "Greek"},
|
| 164 |
+
{"code": "tr", "name": "Turkish"},
|
| 165 |
+
{"code": "hu", "name": "Hungarian"},
|
| 166 |
+
{"code": "fi", "name": "Finnish"},
|
| 167 |
+
{"code": "sv", "name": "Swedish"},
|
| 168 |
+
{"code": "da", "name": "Danish"},
|
| 169 |
+
{"code": "no", "name": "Norwegian"},
|
| 170 |
+
{"code": "et", "name": "Estonian"},
|
| 171 |
+
{"code": "lv", "name": "Latvian"},
|
| 172 |
+
{"code": "lt", "name": "Lithuanian"},
|
| 173 |
+
{"code": "is", "name": "Icelandic"},
|
| 174 |
+
{"code": "ga", "name": "Irish"},
|
| 175 |
+
{"code": "cy", "name": "Welsh"},
|
| 176 |
+
{"code": "mt", "name": "Maltese"},
|
| 177 |
+
{"code": "sq", "name": "Albanian"},
|
| 178 |
+
{"code": "hy", "name": "Armenian"},
|
| 179 |
+
{"code": "ka", "name": "Georgian"},
|
| 180 |
+
{"code": "az", "name": "Azerbaijani"},
|
| 181 |
+
{"code": "kk", "name": "Kazakh"},
|
| 182 |
+
{"code": "ky", "name": "Kyrgyz"},
|
| 183 |
+
{"code": "tg", "name": "Tajik"},
|
| 184 |
+
{"code": "uz", "name": "Uzbek"},
|
| 185 |
+
{"code": "tk", "name": "Turkmen"},
|
| 186 |
+
{"code": "mn", "name": "Mongolian"},
|
| 187 |
+
{"code": "ar", "name": "Arabic"},
|
| 188 |
+
{"code": "fa", "name": "Persian"},
|
| 189 |
+
{"code": "iw", "name": "Hebrew"},
|
| 190 |
+
{"code": "ps", "name": "Pashto"},
|
| 191 |
+
{"code": "ug", "name": "Uyghur"},
|
| 192 |
+
{"code": "ku", "name": "Kurdish (Kurmanji)"},
|
| 193 |
+
{"code": "sw", "name": "Swahili"},
|
| 194 |
+
{"code": "am", "name": "Amharic"},
|
| 195 |
+
{"code": "ha", "name": "Hausa"},
|
| 196 |
+
{"code": "ig", "name": "Igbo"},
|
| 197 |
+
{"code": "yo", "name": "Yoruba"},
|
| 198 |
+
{"code": "zu", "name": "Zulu"},
|
| 199 |
+
{"code": "xh", "name": "Xhosa"},
|
| 200 |
+
{"code": "so", "name": "Somali"},
|
| 201 |
+
{"code": "rw", "name": "Kinyarwanda"},
|
| 202 |
+
{"code": "mg", "name": "Malagasy"},
|
| 203 |
+
{"code": "af", "name": "Afrikaans"},
|
| 204 |
+
{"code": "ca", "name": "Catalan"},
|
| 205 |
+
{"code": "eu", "name": "Basque"},
|
| 206 |
+
{"code": "gl", "name": "Galician"},
|
| 207 |
+
{"code": "eo", "name": "Esperanto"},
|
| 208 |
+
{"code": "be", "name": "Belarusian"},
|
| 209 |
+
{"code": "ceb", "name": "Cebuano"},
|
| 210 |
+
{"code": "co", "name": "Corsican"},
|
| 211 |
+
{"code": "fy", "name": "Frisian"},
|
| 212 |
+
{"code": "haw", "name": "Hawaiian"},
|
| 213 |
+
{"code": "hmn", "name": "Hmong"},
|
| 214 |
+
{"code": "ht", "name": "Haitian Creole"},
|
| 215 |
+
{"code": "lb", "name": "Luxembourgish"},
|
| 216 |
+
{"code": "la", "name": "Latin"},
|
| 217 |
+
{"code": "mi", "name": "Maori"},
|
| 218 |
+
{"code": "or", "name": "Odia (Oriya)"},
|
| 219 |
+
{"code": "gd", "name": "Scots Gaelic"},
|
| 220 |
+
{"code": "sm", "name": "Samoan"},
|
| 221 |
+
{"code": "sn", "name": "Shona"},
|
| 222 |
+
{"code": "st", "name": "Sesotho"},
|
| 223 |
+
{"code": "sd", "name": "Sindhi"},
|
| 224 |
+
{"code": "tt", "name": "Tatar"},
|
| 225 |
+
{"code": "yi", "name": "Yiddish"},
|
| 226 |
+
{"code": "ny", "name": "Chichewa"}
|
| 227 |
]
|
| 228 |
|
| 229 |
+
UI_LANGUAGE_CODE_MAP = {
|
| 230 |
+
"en": "en",
|
| 231 |
+
"th": "th",
|
| 232 |
+
"ja": "ja",
|
| 233 |
+
"ko": "ko",
|
| 234 |
+
"zh-cn": "zh-CN",
|
| 235 |
+
"zh-tw": "zh-TW",
|
| 236 |
+
"vi": "vi",
|
| 237 |
+
"id": "id",
|
| 238 |
+
"ms": "ms",
|
| 239 |
+
"tl": "tl",
|
| 240 |
+
"fil": "fil",
|
| 241 |
+
"hi": "hi",
|
| 242 |
+
"bn": "bn",
|
| 243 |
+
"ur": "ur",
|
| 244 |
+
"ta": "ta",
|
| 245 |
+
"te": "te",
|
| 246 |
+
"ml": "ml",
|
| 247 |
+
"mr": "mr",
|
| 248 |
+
"gu": "gu",
|
| 249 |
+
"kn": "kn",
|
| 250 |
+
"pa": "pa",
|
| 251 |
+
"ne": "ne",
|
| 252 |
+
"si": "si",
|
| 253 |
+
"my": "my",
|
| 254 |
+
"km": "km",
|
| 255 |
+
"lo": "lo",
|
| 256 |
+
"jv": "jv",
|
| 257 |
+
"su": "su",
|
| 258 |
+
"es": "es",
|
| 259 |
+
"fr": "fr",
|
| 260 |
+
"de": "de",
|
| 261 |
+
"it": "it",
|
| 262 |
+
"pt": "pt",
|
| 263 |
+
"nl": "nl",
|
| 264 |
+
"pl": "pl",
|
| 265 |
+
"ro": "ro",
|
| 266 |
+
"ru": "ru",
|
| 267 |
+
"uk": "uk",
|
| 268 |
+
"cs": "cs",
|
| 269 |
+
"sk": "sk",
|
| 270 |
+
"sl": "sl",
|
| 271 |
+
"hr": "hr",
|
| 272 |
+
"sr": "sr",
|
| 273 |
+
"bs": "bs",
|
| 274 |
+
"bg": "bg",
|
| 275 |
+
"mk": "mk",
|
| 276 |
+
"el": "el",
|
| 277 |
+
"tr": "tr",
|
| 278 |
+
"hu": "hu",
|
| 279 |
+
"fi": "fi",
|
| 280 |
+
"sv": "sv",
|
| 281 |
+
"da": "da",
|
| 282 |
+
"no": "no",
|
| 283 |
+
"et": "et",
|
| 284 |
+
"lv": "lv",
|
| 285 |
+
"lt": "lt",
|
| 286 |
+
"is": "is",
|
| 287 |
+
"ga": "ga",
|
| 288 |
+
"cy": "cy",
|
| 289 |
+
"mt": "mt",
|
| 290 |
+
"sq": "sq",
|
| 291 |
+
"hy": "hy",
|
| 292 |
+
"ka": "ka",
|
| 293 |
+
"az": "az",
|
| 294 |
+
"kk": "kk",
|
| 295 |
+
"ky": "ky",
|
| 296 |
+
"tg": "tg",
|
| 297 |
+
"uz": "uz",
|
| 298 |
+
"tk": "tk",
|
| 299 |
+
"mn": "mn",
|
| 300 |
+
"ar": "ar",
|
| 301 |
+
"fa": "fa",
|
| 302 |
+
"iw": "iw",
|
| 303 |
+
"ps": "ps",
|
| 304 |
+
"ug": "ug",
|
| 305 |
+
"ku": "ku",
|
| 306 |
+
"sw": "sw",
|
| 307 |
+
"am": "am",
|
| 308 |
+
"ha": "ha",
|
| 309 |
+
"ig": "ig",
|
| 310 |
+
"yo": "yo",
|
| 311 |
+
"zu": "zu",
|
| 312 |
+
"xh": "xh",
|
| 313 |
+
"so": "so",
|
| 314 |
+
"rw": "rw",
|
| 315 |
+
"mg": "mg",
|
| 316 |
+
"af": "af",
|
| 317 |
+
"ca": "ca",
|
| 318 |
+
"eu": "eu",
|
| 319 |
+
"gl": "gl",
|
| 320 |
+
"eo": "eo",
|
| 321 |
+
"be": "be",
|
| 322 |
+
"ceb": "ceb",
|
| 323 |
+
"co": "co",
|
| 324 |
+
"fy": "fy",
|
| 325 |
+
"haw": "haw",
|
| 326 |
+
"hmn": "hmn",
|
| 327 |
+
"ht": "ht",
|
| 328 |
+
"lb": "lb",
|
| 329 |
+
"la": "la",
|
| 330 |
+
"mi": "mi",
|
| 331 |
+
"or": "or",
|
| 332 |
+
"gd": "gd",
|
| 333 |
+
"sm": "sm",
|
| 334 |
+
"sn": "sn",
|
| 335 |
+
"st": "st",
|
| 336 |
+
"sd": "sd",
|
| 337 |
+
"tt": "tt",
|
| 338 |
+
"yi": "yi",
|
| 339 |
+
"ny": "ny"
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
AI_PROVIDER_DEFAULTS = {
|
| 343 |
"gemini": {
|
| 344 |
"model": "gemini-2.5-flash",
|
|
|
|
| 434 |
"Choose 丁寧語/タメ口 to match context; keep emotion and emphasis.\n"
|
| 435 |
"Keep proper nouns consistent; keep SFX natural in Japanese."
|
| 436 |
),
|
| 437 |
+
"id": (
|
| 438 |
+
"Target language: Indonesian\n"
|
| 439 |
+
"Write natural Indonesian manga dialogue: concise, conversational, and easy to read in speech bubbles.\n"
|
| 440 |
+
"Keep tone, emotion, and character voice intact; avoid stiff literal phrasing.\n"
|
| 441 |
+
"Use everyday Indonesian unless the source clearly needs a formal register.\n"
|
| 442 |
+
"Keep names and recurring terms consistent; avoid over-explaining."
|
| 443 |
+
),
|
| 444 |
"default": (
|
| 445 |
"Write natural manga dialogue in the target language: concise, spoken, faithful to meaning and tone."
|
| 446 |
),
|
|
|
|
| 505 |
|
| 506 |
def _normalize_lang(lang: str) -> str:
|
| 507 |
t = (lang or "").strip().lower()
|
| 508 |
+
alias_map = {
|
| 509 |
+
"jp": "ja",
|
| 510 |
+
"jpn": "ja",
|
| 511 |
+
"japanese": "ja",
|
| 512 |
+
"thai": "th",
|
| 513 |
+
"eng": "en",
|
| 514 |
+
"english": "en",
|
| 515 |
+
"indonesian": "id",
|
| 516 |
+
"bahasa indonesia": "id",
|
| 517 |
+
"bahasa_indonesia": "id",
|
| 518 |
+
"indo": "id",
|
| 519 |
+
"he": "iw",
|
| 520 |
+
"hebrew": "iw",
|
| 521 |
+
"tagalog": "tl",
|
| 522 |
+
"filipino": "fil",
|
| 523 |
+
"burmese": "my",
|
| 524 |
+
}
|
| 525 |
+
if t in alias_map:
|
| 526 |
+
t = alias_map[t]
|
| 527 |
+
if t in UI_LANGUAGE_CODE_MAP:
|
| 528 |
+
return UI_LANGUAGE_CODE_MAP[t]
|
| 529 |
+
if t in ("zh", "zh-hans", "zh_cn", "zh-cn", "zh_hans"):
|
| 530 |
+
return "zh-CN"
|
| 531 |
+
if t in ("zh-hant", "zh_tw", "zh-tw", "zh_hant"):
|
| 532 |
+
return "zh-TW"
|
| 533 |
if len(t) >= 2:
|
| 534 |
return t[:2]
|
| 535 |
return t
|
|
|
|
| 3036 |
baseline_to_center = -((min_t + max_b) / 2.0)
|
| 3037 |
return width, total_h, baseline_to_center
|
| 3038 |
|
| 3039 |
+
def _angle_diff_deg(a: float, b: float) -> float:
|
| 3040 |
+
d = float(a) - float(b)
|
| 3041 |
+
while d <= -180.0:
|
| 3042 |
+
d += 360.0
|
| 3043 |
+
while d > 180.0:
|
| 3044 |
+
d -= 360.0
|
| 3045 |
+
return d
|
| 3046 |
+
|
| 3047 |
+
|
| 3048 |
+
def _token_center_px(t: dict, W: int, H: int) -> tuple[float, float]:
|
| 3049 |
+
b = t.get("box") or {}
|
| 3050 |
+
c = b.get("center") or {}
|
| 3051 |
+
if ("x" in c) and ("y" in c):
|
| 3052 |
+
return float(c.get("x") or 0.0) * float(W), float(c.get("y") or 0.0) * float(H)
|
| 3053 |
+
left = float(b.get("left") or 0.0) * float(W)
|
| 3054 |
+
top = float(b.get("top") or 0.0) * float(H)
|
| 3055 |
+
width = float(b.get("width") or 0.0) * float(W)
|
| 3056 |
+
height = float(b.get("height") or 0.0) * float(H)
|
| 3057 |
+
return left + (width / 2.0), top + (height / 2.0)
|
| 3058 |
+
|
| 3059 |
+
|
| 3060 |
+
def _token_tangent_normal_px(t: dict, W: int, H: int):
|
| 3061 |
+
p1 = t.get("baseline_p1") or {}
|
| 3062 |
+
p2 = t.get("baseline_p2") or {}
|
| 3063 |
+
if ("x" in p1 and "y" in p1 and "x" in p2 and "y" in p2):
|
| 3064 |
+
x1 = float(p1.get("x") or 0.0) * float(W)
|
| 3065 |
+
y1 = float(p1.get("y") or 0.0) * float(H)
|
| 3066 |
+
x2 = float(p2.get("x") or 0.0) * float(W)
|
| 3067 |
+
y2 = float(p2.get("y") or 0.0) * float(H)
|
| 3068 |
+
dx = x2 - x1
|
| 3069 |
+
dy = y2 - y1
|
| 3070 |
+
L = float(math.hypot(dx, dy))
|
| 3071 |
+
if L > 1e-6:
|
| 3072 |
+
ux = dx / L
|
| 3073 |
+
uy = dy / L
|
| 3074 |
+
return ux, uy, -uy, ux
|
| 3075 |
+
angle_deg = float((t.get("box") or {}).get("rotation_deg") or 0.0)
|
| 3076 |
+
rad = math.radians(angle_deg)
|
| 3077 |
+
ux = math.cos(rad)
|
| 3078 |
+
uy = math.sin(rad)
|
| 3079 |
+
return ux, uy, -uy, ux
|
| 3080 |
+
|
| 3081 |
+
|
| 3082 |
+
def _build_curve_context(tokens: list, W: int, H: int) -> dict:
|
| 3083 |
+
para_items = {}
|
| 3084 |
+
for t in tokens or []:
|
| 3085 |
+
pi = t.get("para_index")
|
| 3086 |
+
ii = t.get("item_index")
|
| 3087 |
+
if pi is None or ii is None:
|
| 3088 |
+
continue
|
| 3089 |
+
key = (int(pi), int(ii))
|
| 3090 |
+
if key in para_items:
|
| 3091 |
+
continue
|
| 3092 |
+
cx, cy = _token_center_px(t, W, H)
|
| 3093 |
+
ux, uy, nx, ny = _token_tangent_normal_px(t, W, H)
|
| 3094 |
+
b = t.get("box") or {}
|
| 3095 |
+
para_items[key] = {
|
| 3096 |
+
"cx": float(cx),
|
| 3097 |
+
"cy": float(cy),
|
| 3098 |
+
"ux": float(ux),
|
| 3099 |
+
"uy": float(uy),
|
| 3100 |
+
"nx": float(nx),
|
| 3101 |
+
"ny": float(ny),
|
| 3102 |
+
"w": max(1.0, float(b.get("width") or 0.0) * float(W)),
|
| 3103 |
+
"h": max(1.0, float(b.get("height") or 0.0) * float(H)),
|
| 3104 |
+
"angle": float(b.get("rotation_deg") or 0.0),
|
| 3105 |
+
}
|
| 3106 |
+
|
| 3107 |
+
grouped = {}
|
| 3108 |
+
for (pi, ii), data in para_items.items():
|
| 3109 |
+
grouped.setdefault(int(pi), []).append((int(ii), data))
|
| 3110 |
+
|
| 3111 |
+
out = {}
|
| 3112 |
+
for pi, entries in grouped.items():
|
| 3113 |
+
entries.sort(key=lambda it: it[0])
|
| 3114 |
+
n = len(entries)
|
| 3115 |
+
for idx, (ii, cur) in enumerate(entries):
|
| 3116 |
+
prev_data = entries[idx - 1][1] if idx > 0 else None
|
| 3117 |
+
next_data = entries[idx + 1][1] if (idx + 1) < n else None
|
| 3118 |
+
curve_px = 0.0
|
| 3119 |
+
if prev_data and next_data:
|
| 3120 |
+
ax = prev_data["cx"]
|
| 3121 |
+
ay = prev_data["cy"]
|
| 3122 |
+
bx = next_data["cx"]
|
| 3123 |
+
by = next_data["cy"]
|
| 3124 |
+
vx = bx - ax
|
| 3125 |
+
vy = by - ay
|
| 3126 |
+
chord = float(math.hypot(vx, vy))
|
| 3127 |
+
if chord > 1e-6:
|
| 3128 |
+
wx = cur["cx"] - ax
|
| 3129 |
+
wy = cur["cy"] - ay
|
| 3130 |
+
signed = ((vx * wy) - (vy * wx)) / chord
|
| 3131 |
+
turn1 = math.degrees(math.atan2(cur["cy"] - ay, cur["cx"] - ax))
|
| 3132 |
+
turn2 = math.degrees(math.atan2(by - cur["cy"], bx - cur["cx"]))
|
| 3133 |
+
bend_deg = _angle_diff_deg(turn2, turn1)
|
| 3134 |
+
bend_sign = 1.0 if bend_deg >= 0.0 else -1.0
|
| 3135 |
+
if signed != 0.0 and bend_deg != 0.0 and (1.0 if signed >= 0.0 else -1.0) != bend_sign:
|
| 3136 |
+
signed = -signed
|
| 3137 |
+
chord_span = max(cur["w"], chord * 0.5)
|
| 3138 |
+
angle_mag = abs(float(bend_deg))
|
| 3139 |
+
angle_bonus = min(cur["h"] * 0.32, chord_span * 0.1, angle_mag * 0.18)
|
| 3140 |
+
curve_px = signed + (bend_sign * angle_bonus)
|
| 3141 |
+
elif prev_data or next_data:
|
| 3142 |
+
near = prev_data or next_data
|
| 3143 |
+
ref_angle = math.degrees(math.atan2(cur["cy"] - near["cy"], cur["cx"] - near["cx"]))
|
| 3144 |
+
bend_deg = _angle_diff_deg(cur["angle"], ref_angle)
|
| 3145 |
+
if abs(bend_deg) >= 8.0:
|
| 3146 |
+
sign = 1.0 if bend_deg >= 0.0 else -1.0
|
| 3147 |
+
curve_px = sign * min(cur["h"] * 0.2, cur["w"] * 0.06, abs(bend_deg) * 0.2)
|
| 3148 |
+
if curve_px:
|
| 3149 |
+
limit = min(cur["h"] * 0.72, cur["w"] * 0.18, 42.0)
|
| 3150 |
+
if abs(curve_px) < max(2.0, cur["h"] * 0.12):
|
| 3151 |
+
curve_px = 0.0
|
| 3152 |
+
else:
|
| 3153 |
+
curve_px = max(-limit, min(limit, curve_px))
|
| 3154 |
+
out[(pi, ii)] = float(curve_px)
|
| 3155 |
+
return out
|
| 3156 |
+
|
| 3157 |
+
|
| 3158 |
+
def _estimate_curve_px(token: dict, curve_map: dict, avail_w: float, avail_h: float, font_size: int, text_w: float, text_h: float) -> float:
|
| 3159 |
+
pi = token.get("para_index")
|
| 3160 |
+
ii = token.get("item_index")
|
| 3161 |
+
curve_px = 0.0
|
| 3162 |
+
if pi is not None and ii is not None:
|
| 3163 |
+
curve_px = float(curve_map.get((int(pi), int(ii))) or 0.0)
|
| 3164 |
+
|
| 3165 |
+
if not curve_px:
|
| 3166 |
+
b = token.get("box") or {}
|
| 3167 |
+
cx = float((b.get("center") or {}).get("x") or (float(b.get("left") or 0.0) + (float(b.get("width") or 0.0) / 2.0)))
|
| 3168 |
+
cy = float((b.get("center") or {}).get("y") or (float(b.get("top") or 0.0) + (float(b.get("height") or 0.0) / 2.0)))
|
| 3169 |
+
p1 = token.get("baseline_p1") or {}
|
| 3170 |
+
p2 = token.get("baseline_p2") or {}
|
| 3171 |
+
if ("x" in p1 and "y" in p1 and "x" in p2 and "y" in p2):
|
| 3172 |
+
mx = (float(p1.get("x") or 0.0) + float(p2.get("x") or 0.0)) / 2.0
|
| 3173 |
+
my = (float(p1.get("y") or 0.0) + float(p2.get("y") or 0.0)) / 2.0
|
| 3174 |
+
ux, uy, nx, ny = _token_tangent_normal_px(token, 1, 1)
|
| 3175 |
+
off = ((cx - mx) * nx) + ((cy - my) * ny)
|
| 3176 |
+
curve_px = float(off) * min(avail_w, avail_h)
|
| 3177 |
+
|
| 3178 |
+
if not curve_px:
|
| 3179 |
+
return 0.0
|
| 3180 |
+
|
| 3181 |
+
cap_h = max(text_h * 0.55, avail_h * 0.3)
|
| 3182 |
+
cap = min(max(4.0, cap_h), max(4.0, avail_h * 0.82), max(4.0, avail_w * 0.18), max(4.0, font_size * 0.95))
|
| 3183 |
+
curve_px = max(-cap, min(cap, curve_px))
|
| 3184 |
+
if abs(curve_px) < 2.0:
|
| 3185 |
+
return 0.0
|
| 3186 |
+
return float(curve_px)
|
| 3187 |
+
|
| 3188 |
+
|
| 3189 |
+
def _curve_height_extra_px(curve_px: float) -> float:
|
| 3190 |
+
return abs(float(curve_px)) * 0.9
|
| 3191 |
+
|
| 3192 |
+
|
| 3193 |
+
def _warp_canvas_arc(canvas: Image.Image, curve_px: float) -> Image.Image:
|
| 3194 |
+
curve = float(curve_px or 0.0)
|
| 3195 |
+
if abs(curve) < 1.0:
|
| 3196 |
+
return canvas
|
| 3197 |
+
arr = np.array(canvas, dtype=np.uint8)
|
| 3198 |
+
if arr.ndim != 3 or arr.shape[2] != 4:
|
| 3199 |
+
return canvas
|
| 3200 |
+
h, w, _ = arr.shape
|
| 3201 |
+
if h <= 0 or w <= 1:
|
| 3202 |
+
return canvas
|
| 3203 |
+
pad = int(math.ceil(abs(curve))) + 4
|
| 3204 |
+
out = np.zeros((h + (pad * 2), w, 4), dtype=np.uint8)
|
| 3205 |
+
denom = float(max(1, w - 1))
|
| 3206 |
+
for x in range(w):
|
| 3207 |
+
xn = (2.0 * float(x) / denom) - 1.0
|
| 3208 |
+
bow = 1.0 - (xn * xn)
|
| 3209 |
+
shift = int(round(curve * bow))
|
| 3210 |
+
y0 = pad + shift
|
| 3211 |
+
y1 = y0 + h
|
| 3212 |
+
if y0 < 0:
|
| 3213 |
+
src_top = -y0
|
| 3214 |
+
y0 = 0
|
| 3215 |
+
else:
|
| 3216 |
+
src_top = 0
|
| 3217 |
+
if y1 > out.shape[0]:
|
| 3218 |
+
src_bottom = h - (y1 - out.shape[0])
|
| 3219 |
+
y1 = out.shape[0]
|
| 3220 |
+
else:
|
| 3221 |
+
src_bottom = h
|
| 3222 |
+
if y1 <= y0 or src_bottom <= src_top:
|
| 3223 |
+
continue
|
| 3224 |
+
out[y0:y1, x, :] = arr[src_top:src_bottom, x, :]
|
| 3225 |
+
return Image.fromarray(out, mode="RGBA")
|
| 3226 |
+
|
| 3227 |
def _item_avail_w_px(item: dict, W: int, H: int) -> float:
|
| 3228 |
b = item.get("box") or {}
|
| 3229 |
w_box = float(b.get("width") or 0.0) * float(W)
|
|
|
|
| 3865 |
base_rgb = img.convert("RGB")
|
| 3866 |
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
|
| 3867 |
draw = ImageDraw.Draw(overlay)
|
| 3868 |
+
curve_map = _build_curve_context(tokens or [], base.size[0], base.size[1])
|
| 3869 |
|
| 3870 |
for ol in (level_outlines or []):
|
| 3871 |
q = ol.get("quad")
|
|
|
|
| 4007 |
except Exception:
|
| 4008 |
tw2, th2 = d2.textsize(text, font=font)
|
| 4009 |
|
| 4010 |
+
curve_px = _estimate_curve_px(t, curve_map, avail_w, avail_h, final_size, tw2, th2)
|
| 4011 |
+
side = int(max(tw2, th2 + _curve_height_extra_px(curve_px), avail_h, avail_w) * 2.35 + 40)
|
| 4012 |
side = min(side, int(max(W, H) * 4))
|
| 4013 |
if side < 128:
|
| 4014 |
side = 128
|
|
|
|
| 4097 |
|
| 4098 |
_draw_text_baseline_fallback(
|
| 4099 |
dc, origin, text, thai_path, latin_path, final_size, fill)
|
| 4100 |
+
if curve_px:
|
| 4101 |
+
canvas = _warp_canvas_arc(canvas, curve_px)
|
| 4102 |
+
origin = (canvas.size[0] // 2, canvas.size[1] // 2)
|
| 4103 |
rotated = canvas.rotate(-angle_deg, resample=Image.BICUBIC,
|
| 4104 |
expand=False, center=origin)
|
| 4105 |
paste_x = int(round(bx - origin[0]))
|
|
|
|
| 4108 |
else:
|
| 4109 |
_draw_text_centered_fallback(
|
| 4110 |
dc, origin, text, thai_path, latin_path, final_size, fill)
|
| 4111 |
+
if curve_px:
|
| 4112 |
+
canvas = _warp_canvas_arc(canvas, curve_px)
|
| 4113 |
+
origin = (canvas.size[0] // 2, canvas.size[1] // 2)
|
| 4114 |
rotated = canvas.rotate(-angle_deg, resample=Image.BICUBIC,
|
| 4115 |
expand=False, center=origin)
|
| 4116 |
paste_x = int(round(box_cx - origin[0]))
|