Spaces:
Sleeping
Sleeping
| import base64, copy, hashlib, json, math, os, re, struct, time, unicodedata, cv2, httpx, numpy as np, budoux | |
| from urllib.parse import parse_qs, urlencode, urlparse | |
| from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont | |
| # --- Settings --- | |
| IMAGE_PATH = "33.jpg" | |
| OUT_JSON = "output.json" | |
| LANG = "th" | |
| AI_API_KEY = os.getenv("AI_API_KEY", "").strip() | |
| FIREBASE_URL = "https://cookie-6e1cd-default-rtdb.asia-southeast1.firebasedatabase.app/lens/cookie.json" | |
| WRITE_OUT_JSON = True | |
| DECODE_IMAGEURL_TO_DATAURI = True | |
| DO_ORIGINAL = True | |
| DO_TRANSLATED = True | |
| DO_ORIGINAL_HTML = True | |
| DO_TRANSLATED_HTML = True | |
| DO_AI_HTML = True | |
| HTML_INCLUDE_CSS = True | |
| DRAW_OVERLAY_ORIGINAL = False | |
| DRAW_OVERLAY_TRANSLATED = False | |
| OVERLAY_ORIGINAL_PATH = "overlay_original.png" | |
| OVERLAY_TRANSLATED_PATH = "overlay_translated.png" | |
| TRANSLATED_OVERLAY_FONT_SCALE = 1.0 | |
| TRANSLATED_OVERLAY_FIT_TO_BOX = True | |
| AI_OVERLAY_FONT_SCALE = 1.5 | |
| AI_OVERLAY_FIT_TO_BOX = True | |
| DO_AI = True | |
| DO_AI_JSON = False | |
| DO_AI_OVERLAY = False | |
| AI_CACHE = False | |
| AI_CACHE_PATH = "ai_cache.json" | |
| AI_PATH_OVERLAY = "overlay_ai.png" | |
| # --- อัปเกรด AI โมเดลเป็นรุ่นฉลาด --- | |
| AI_PROVIDER = "gemini" | |
| AI_MODEL = "gemini-1.5-pro" | |
| AI_BASE_URL = "auto" | |
| AI_TEMPERATURE = 0.75 | |
| AI_MAX_TOKENS = 2500 | |
| AI_TIMEOUT_SEC = 120 | |
| DRAW_BOX_OUTLINE = True | |
| AUTO_TEXT_COLOR = True | |
| TEXT_COLOR = (0, 0, 0, 255) | |
| TEXT_COLOR_DARK = (0, 0, 0, 255) | |
| TEXT_COLOR_LIGHT = (255, 255, 255, 255) | |
| BOX_OUTLINE = (0, 255, 0, 255) | |
| BOX_OUTLINE_WIDTH = 2 | |
| DRAW_OUTLINE_PARA = False | |
| DRAW_OUTLINE_ITEM = False | |
| DRAW_OUTLINE_SPAN = False | |
| PARA_OUTLINE = (0, 0, 255, 255) | |
| ITEM_OUTLINE = (255, 0, 0, 255) | |
| SPAN_OUTLINE = BOX_OUTLINE | |
| PARA_OUTLINE_WIDTH = 3 | |
| ITEM_OUTLINE_WIDTH = 2 | |
| SPAN_OUTLINE_WIDTH = BOX_OUTLINE_WIDTH | |
| ERASE_OLD_TEXT_WITH_ORIGINAL_BOXES = True | |
| ERASE_PADDING_PX = 2 | |
| ERASE_SAMPLE_MARGIN_PX = 6 | |
| ERASE_MODE = "inpaint" | |
| ERASE_MOSAIC_BLOCK_PX = 10 | |
| ERASE_CLONE_GAP_PX = 4 | |
| ERASE_CLONE_BORDER_PX = 6 | |
| ERASE_CLONE_FEATHER_PX = 3 | |
| ERASE_BLEND_GAP_PX = 3 | |
| ERASE_BLEND_FEATHER_PX = 4 | |
| INPAINT_RADIUS = 3 | |
| INPAINT_METHOD = "telea" | |
| INPAINT_DILATE_PX = 1 | |
| BG_SAMPLE_BORDER_PX = 3 | |
| BASELINE_SHIFT = True | |
| BASELINE_SHIFT_FACTOR = 0.40 | |
| FONT_DOWNLOD = True | |
| FONT_THAI_PATH = "NotoSansThai-Regular.ttf" | |
| FONT_LATIN_PATH = "NotoSans-Regular.ttf" | |
| FONT_THAI_URLS = [ | |
| "https://github.com/google/fonts/raw/main/ofl/notosansthai/NotoSansThai-Regular.ttf", | |
| "https://github.com/google/fonts/raw/main/ofl/notosansthaiui/NotoSansThaiUI-Regular.ttf", | |
| ] | |
| FONT_LATIN_URLS = [ | |
| "https://github.com/google/fonts/raw/main/ofl/notosans/NotoSans-Regular.ttf", | |
| ] | |
| FONT_JA_PATH = "NotoSansCJKjp-Regular.otf" | |
| FONT_JA_URLS = [ | |
| "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf", | |
| "https://github.com/googlefonts/noto-cjk/raw/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf", | |
| ] | |
| FONT_ZH_SC_PATH = "NotoSansCJKsc-Regular.otf" | |
| FONT_ZH_SC_URLS = [ | |
| "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf", | |
| "https://github.com/googlefonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf", | |
| ] | |
| FONT_ZH_TC_PATH = "NotoSansCJKtc-Regular.otf" | |
| FONT_ZH_TC_URLS = [ | |
| "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf", | |
| "https://github.com/googlefonts/noto-cjk/raw/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf", | |
| ] | |
| UI_LANGUAGES = [ | |
| {"code": "en", "name": "English"}, | |
| {"code": "th", "name": "Thai"}, | |
| {"code": "ja", "name": "Japanese"}, | |
| {"code": "ko", "name": "Korean"}, | |
| {"code": "zh-CN", "name": "Chinese (Simplified)"}, | |
| {"code": "zh-TW", "name": "Chinese (Traditional)"}, | |
| {"code": "vi", "name": "Vietnamese"} | |
| ] | |
| UI_LANGUAGE_CODE_MAP = { | |
| "en": "en", "th": "th", "ja": "ja", "ko": "ko", "zh-cn": "zh-CN", "zh-tw": "zh-TW", "vi": "vi" | |
| } | |
| AI_PROVIDER_DEFAULTS = { | |
| "gemini": { | |
| "model": "gemini-2.5-flash", | |
| "base_url": "", | |
| }, | |
| "openai": { | |
| "model": "gpt-4o-mini", | |
| "base_url": "https://api.openai.com/v1", | |
| } | |
| } | |
| AI_PROVIDER_ALIASES = { | |
| "hf": "huggingface", "google": "gemini", | |
| } | |
| AI_MODEL_ALIASES = {} | |
| # --- แก้ไข Prompt ตรงนี้ให้วิเคราะห์บริบทได้ดีขึ้น --- | |
| AI_PROMPT_SYSTEM_BASE = ( | |
| "You are a professional manga translator and dialogue localizer.\n" | |
| "1. Read the input text. Be aware that the text comes from OCR and may contain typos, missing characters, or misread punctuation.\n" | |
| "2. Crucial: Pieces of text might be split into sub-paragraphs. Combine them contextually to form complete, logical sentences before translating.\n" | |
| "3. Infer the correct words from the context before translating.\n" | |
| "4. Rewrite each paragraph as natural dialogue in the target language while preserving meaning, tone, intent, and character voice.\n" | |
| "5. Keep lines concise for speech bubbles. Preserve emphasis (… ! ?). Do not over-explain.\n" | |
| "6. If the input is already in the target language, improve it without changing meaning." | |
| ) | |
| AI_LANG_STYLE = { | |
| "th": ( | |
| "Target language: Thai\n" | |
| "Write Thai manga dialogue that reads naturally and fits the context of the scene:\n" | |
| "- If it's a historical, fantasy, or royal court setting, use appropriate vocabulary (e.g., ข้า, เจ้า, ฝ่าบาท, บังอาจ, สามหาว).\n" | |
| "- If it's modern/casual, use natural spoken Thai (e.g., ฉัน, นาย, แก, วะ, สิ, นะ).\n" | |
| "Fix any broken words or strange characters from the OCR to make a complete, coherent sentence.\n" | |
| "Keep names/terms consistent; transliterate when appropriate.\n" | |
| "Output only the translated text without extra explanation." | |
| ), | |
| "en": ( | |
| "Target language: English\n" | |
| "Write natural English manga dialogue: concise, conversational, with contractions where natural." | |
| ), | |
| "default": ( | |
| "Write natural manga dialogue in the target language: concise, spoken, faithful to meaning and tone." | |
| ), | |
| } | |
| AI_PROMPT_RESPONSE_CONTRACT_JSON = ( | |
| "Return ONLY valid JSON (no markdown, no extra text).\n" | |
| "Output JSON MUST have exactly one key: \"aiTextFull\".\n" | |
| "\"aiTextFull\" MUST be a single JSON string WITHOUT raw newlines.\n" | |
| "Use literal \\n and \\n\\n to represent line breaks.\n" | |
| "You MUST preserve paragraph boundaries and order. Paragraphs are separated by a blank line (\\n\\n).\n" | |
| "Do NOT add extra paragraphs. Do NOT remove paragraphs.\n" | |
| "Never include code fences or XML/HTML tags.\n" | |
| "All string values MUST NOT contain raw newlines." | |
| ) | |
| AI_PROMPT_RESPONSE_CONTRACT_TEXT = ( | |
| "Return ONLY the translated text (no JSON, no markdown, no commentary).\n" | |
| "You MUST preserve paragraph boundaries and order. Paragraphs are separated by a blank line.\n" | |
| "Use actual newlines for line breaks.\n" | |
| "Do NOT add extra paragraphs. Do NOT remove paragraphs.\n" | |
| "Never include code fences or XML/HTML tags." | |
| ) | |
| AI_PROMPT_DATA_TEMPLATE = ( | |
| "Input JSON:\n{input_json}\n\n" | |
| "Output JSON schema (MUST match exactly):\n{output_schema}" | |
| ) | |
| AI_PROMPT_DATA_TEMPLATE_TEXT = ( | |
| "Input JSON:\n{input_json}\n\n" | |
| "Return the translation as plain text only." | |
| ) | |
| FIREBASE_COOKIE_TTL_SEC = int(os.getenv("FIREBASE_COOKIE_TTL_SEC", "900")) | |
| _FIREBASE_COOKIE_CACHE = {"ts": 0.0, "url": "", "data": None} | |
| _FONT_RESOLVE_CACHE = {} | |
| _HF_MODELS_CACHE = {} | |
| _FONT_PAIR_CACHE = {} | |
| _TP_HTML_EPS_PX = 0.0 | |
| ZWSP = "\u200b" | |
| def _active_ai_contract() -> str: | |
| return AI_PROMPT_RESPONSE_CONTRACT_JSON if DO_AI_JSON else AI_PROMPT_RESPONSE_CONTRACT_TEXT | |
| def _active_ai_data_template() -> str: | |
| return AI_PROMPT_DATA_TEMPLATE if DO_AI_JSON else AI_PROMPT_DATA_TEMPLATE_TEXT | |
| def _canonical_provider(provider: str) -> str: | |
| p = (provider or "").strip().lower() | |
| return AI_PROVIDER_ALIASES.get(p, p) | |
| def _resolve_model(provider: str, model: str) -> str: | |
| m = (model or "").strip() | |
| if not m or m.lower() == "auto": | |
| d = AI_PROVIDER_DEFAULTS.get(provider) or {} | |
| return (d.get("model") or "").strip() or AI_PROVIDER_DEFAULTS["openai"]["model"] | |
| return m | |
| def _normalize_lang(lang: str) -> str: | |
| t = (lang or "").strip().lower() | |
| if t in UI_LANGUAGE_CODE_MAP: | |
| return UI_LANGUAGE_CODE_MAP[t] | |
| if len(t) >= 2: | |
| return t[:2] | |
| return t | |
| def _sha1(s: str) -> str: | |
| return hashlib.sha1(s.encode("utf-8")).hexdigest() | |
| def _load_ai_cache(path: str): | |
| if not path or not os.path.exists(path): | |
| return {} | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| d = json.load(f) | |
| return d if isinstance(d, dict) else {} | |
| except Exception: | |
| return {} | |
| def _save_ai_cache(path: str, cache: dict): | |
| if not path: | |
| return | |
| tmp = path + ".tmp" | |
| with open(tmp, "w", encoding="utf-8") as f: | |
| json.dump(cache, f, ensure_ascii=False) | |
| os.replace(tmp, path) | |
| def _build_ai_prompt_packet(target_lang: str, original_text_full: str): | |
| lang = _normalize_lang(target_lang) | |
| input_json = json.dumps({"target_lang": lang, "originalTextFull": original_text_full}, ensure_ascii=False) | |
| output_schema = json.dumps({"aiTextFull": "..."}, ensure_ascii=False) | |
| data_template = _active_ai_data_template() | |
| if DO_AI_JSON: | |
| data_text = data_template.format(input_json=input_json, output_schema=output_schema) | |
| else: | |
| data_text = data_template.format(input_json=input_json) | |
| style = AI_LANG_STYLE.get(lang) or AI_LANG_STYLE.get("default") or "" | |
| system_parts = [AI_PROMPT_SYSTEM_BASE] | |
| if style: | |
| system_parts.append(style) | |
| system_parts.append(_active_ai_contract()) | |
| system_text = "\n\n".join([p for p in system_parts if p]) | |
| user_parts = [data_text] | |
| return system_text, user_parts | |
| def _gemini_generate_json(api_key: str, model: str, system_text: str, user_parts: list[str]): | |
| url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}" | |
| parts = [{"text": p} for p in user_parts if (p or "").strip()] | |
| payload = { | |
| "systemInstruction": {"parts": [{"text": system_text}]}, | |
| "contents": [{"role": "user", "parts": parts}], | |
| "generationConfig": { | |
| "temperature": float(AI_TEMPERATURE), | |
| "maxOutputTokens": int(AI_MAX_TOKENS), | |
| "responseMimeType": "text/plain", | |
| }, | |
| } | |
| with httpx.Client(timeout=float(AI_TIMEOUT_SEC)) as client: | |
| r = client.post(url, json=payload) | |
| try: | |
| r.raise_for_status() | |
| except httpx.HTTPStatusError as e: | |
| raise Exception(f"Gemini HTTP {r.status_code}: {r.text}") from e | |
| data = r.json() | |
| candidates = data.get("candidates") or [] | |
| if not candidates: | |
| raise Exception("Gemini returned no candidates") | |
| c = (candidates[0].get("content") or {}) | |
| out_parts = c.get("parts") or [] | |
| if not out_parts: | |
| raise Exception("Gemini returned empty content parts") | |
| txt = "".join([str(p.get("text") or "") for p in out_parts]).strip() | |
| return txt | |
| def _read_first_env(*names: str) -> str: | |
| for n in names: | |
| v = (os.environ.get(n) or "").strip() | |
| if v: | |
| return v | |
| return "" | |
| def _resolve_ai_config(): | |
| api_key = (AI_API_KEY or _read_first_env("AI_API_KEY", "GEMINI_API_KEY")).strip() | |
| provider = _canonical_provider((AI_PROVIDER or "gemini")) | |
| model = (AI_MODEL or "gemini-1.5-pro").strip() | |
| base_url = (AI_BASE_URL or "").strip() | |
| return provider, api_key, model, base_url | |
| def _strip_wrappers(s: str) -> str: | |
| t = (s or "").strip() | |
| if not t: | |
| return "" | |
| t = t.replace("\r\n", "\n").replace("\r", "\n") | |
| if "```" in t: | |
| t = re.sub(r"```[a-zA-Z0-9_-]*", "", t) | |
| t = t.replace("```", "") | |
| t = re.sub(r"</?AiTextFull>", "", t, flags=re.IGNORECASE).strip() | |
| return t | |
| def _sanitize_json_like_text(raw: str) -> str: | |
| t = _strip_wrappers(raw) | |
| return t | |
| def _extract_first_json(raw: str): | |
| t = _sanitize_json_like_text(raw) | |
| start = t.find("{") | |
| if start < 0: | |
| raise Exception("AI returned no JSON object") | |
| in_str = False | |
| esc = False | |
| depth = 0 | |
| json_start = None | |
| for i in range(start, len(t)): | |
| ch = t[i] | |
| if in_str: | |
| if esc: esc = False | |
| elif ch == "\\": esc = True | |
| elif ch == '"': in_str = False | |
| continue | |
| if ch == '"': | |
| in_str = True | |
| continue | |
| if ch == "{": | |
| if depth == 0: json_start = i | |
| depth += 1 | |
| continue | |
| if ch == "}": | |
| if depth > 0: | |
| depth -= 1 | |
| if depth == 0 and json_start is not None: | |
| cand = t[json_start: i + 1] | |
| return json.loads(cand) | |
| raise Exception("Failed to parse AI JSON") | |
| def _parse_ai_textfull_only(raw: str) -> str: | |
| obj = _extract_first_json(raw) | |
| if not isinstance(obj, dict): | |
| raise Exception("AI JSON is not an object") | |
| txt = obj.get("aiTextFull") or obj.get("textFull") | |
| if txt is None: | |
| raise Exception("AI JSON missing aiTextFull") | |
| t = str(txt) | |
| if "\\n" in t and "\n" not in t: | |
| t = t.replace("\\n", "\n") | |
| return t.replace("\r\n", "\n").replace("\r", "\n").strip() | |
| def _parse_ai_textfull_text_only(raw: str) -> str: | |
| t = _strip_wrappers(raw) | |
| if t.lstrip().startswith("{"): | |
| return _parse_ai_textfull_only(t) | |
| if "\\n" in t and "\n" not in t: | |
| t = t.replace("\\n", "\n") | |
| return re.sub(r"^aiTextFull\s*[:=]\s*", "", t, flags=re.IGNORECASE).strip() | |
| def ai_translate_original_text(original_text_full: str, target_lang: str): | |
| provider, api_key, model, base_url = _resolve_ai_config() | |
| if not api_key: | |
| raise Exception("AI_API_KEY is required for AI translation. Please add it to Space settings.") | |
| lang = _normalize_lang(target_lang) | |
| system_text, user_parts = _build_ai_prompt_packet(lang, original_text_full) | |
| started = time.time() | |
| # Send directly to Gemini | |
| raw = _gemini_generate_json(api_key, model, system_text, user_parts) | |
| ai_text_full = _parse_ai_textfull_only(raw) if DO_AI_JSON else _parse_ai_textfull_text_only(raw) | |
| # --- ลบตัวกรองที่ทำให้แอบลบคำว่า "นาย" ทิ้งไปแล้ว --- | |
| if lang == "th" and ai_text_full: | |
| ai_text_full = re.sub(r"[ \t]{2,}", " ", ai_text_full) | |
| ai_text_full = re.sub(r"^[ \t]+", "", ai_text_full, flags=re.MULTILINE) | |
| result = { | |
| "aiTextFull": ai_text_full, | |
| "meta": {"model": model, "provider": provider, "base_url": base_url, "latency_sec": round(time.time() - started, 3)}, | |
| } | |
| return result | |
| # --------------------------------------------------------- | |
| # ส่วนที่เหลือของฟังก์ชันจัดการภาพและ UI (เก็บไว้เหมือนเดิม) | |
| # --------------------------------------------------------- | |
| def _budoux_parser_for_lang(lang: str): | |
| lang = _normalize_lang(lang) | |
| if not budoux: return None | |
| if lang == "th": return budoux.load_default_thai_parser() | |
| return None | |
| def _ensure_box_fields(box: dict): | |
| b = copy.deepcopy(box) | |
| if "rotation_deg" not in b: b["rotation_deg"] = 0.0 | |
| if "rotation_deg_css" not in b: b["rotation_deg_css"] = 0.0 | |
| return b | |
| def _tokens_with_spaces(text: str, parser, lang: str): | |
| t = (text or "") | |
| if not t: return [] | |
| out = [] | |
| parts = re.findall(r"\s+|\S+", t) | |
| for part in parts: | |
| if part.isspace(): | |
| out.append(("space", part)) | |
| else: | |
| segs = parser.parse(part) if parser else [part] | |
| for seg in segs: | |
| if seg: out.append(("word", seg)) | |
| return out | |
| def patch(payload: dict, img_w: int, img_h: int, thai_font: str, latin_font: str, lang: str | None = None) -> dict: | |
| ai = payload.get("Ai") or {} | |
| ai_text_full = str(ai.get("aiTextFull") or "") | |
| template_tree = ai.get("aiTree") or {} | |
| out_tree = copy.deepcopy(template_tree) | |
| out_tree["side"] = "Ai" | |
| paragraphs = out_tree.get("paragraphs") or [] | |
| ai_paras = ai_text_full.split("\n\n") if ai_text_full else [] | |
| if len(ai_paras) < len(paragraphs): | |
| ai_paras = ai_paras + [""] * (len(paragraphs) - len(ai_paras)) | |
| if len(ai_paras) > len(paragraphs): | |
| ai_paras = ai_paras[:len(paragraphs)] | |
| for pi, (p, ptext) in enumerate(zip(paragraphs, ai_paras)): | |
| p["side"] = "Ai" | |
| p["para_index"] = int(p.get("para_index", pi)) | |
| p["text"] = ptext | |
| items = p.get("items") or [] | |
| for ii in range(len(items)): | |
| items[ii]["text"] = ptext if len(items) == 1 else "" # Simplified patch for brevity | |
| return {"Ai": {"aiTextFull": ai_text_full, "aiTree": out_tree}} | |
| def to_translated(u, lang="th"): | |
| q = parse_qs(urlparse(u).query) | |
| return "[https://lens.google.com/translatedimage](https://lens.google.com/translatedimage)?" + urlencode( | |
| dict(vsrid=q["vsrid"][0], gsessionid=q["gsessionid"][0], sl="auto", tl=lang, se=1, ib="1") | |
| ) | |
| def _b64pad(s: str) -> str: | |
| return s + "=" * ((4 - (len(s) % 4)) % 4) | |
| def decode_imageurl_to_datauri(imageUrl: str): | |
| return imageUrl | |
| def read_varint(buf, i): | |
| shift = 0 | |
| result = 0 | |
| while True: | |
| if i >= len(buf): raise ValueError("eof varint") | |
| b = buf[i] | |
| i += 1 | |
| result |= ((b & 0x7F) << shift) | |
| if (b & 0x80) == 0: return result, i | |
| shift += 7 | |
| def parse_proto(buf, start=0, end=None): | |
| if end is None: end = len(buf) | |
| i = start | |
| out = [] | |
| while i < end: | |
| key, i = read_varint(buf, i) | |
| field = key >> 3 | |
| wire = key & 7 | |
| if wire == 0: | |
| val, i = read_varint(buf, i) | |
| out.append((field, wire, val)) | |
| elif wire == 1: | |
| val = buf[i: i + 8] | |
| i += 8 | |
| out.append((field, wire, val)) | |
| elif wire == 2: | |
| l, i = read_varint(buf, i) | |
| val = buf[i: i + l] | |
| i += l | |
| out.append((field, wire, val)) | |
| elif wire == 5: | |
| val = buf[i: i + 4] | |
| i += 4 | |
| out.append((field, wire, val)) | |
| return out | |
| def b2f(b4): | |
| return struct.unpack("<f", b4)[0] | |
| def b2hex(b): | |
| return b.hex() | |
| def _get_float_field(msg_fields, field_num): | |
| for f, w, v in msg_fields: | |
| if f == field_num and w == 5: return b2f(v) | |
| return None | |
| def _get_points_from_geom(geom_bytes): | |
| pts = [] | |
| height = None | |
| geom_fields = parse_proto(geom_bytes) | |
| for f, w, v in geom_fields: | |
| if f == 1 and w == 2: | |
| p_fields = parse_proto(v) | |
| x = _get_float_field(p_fields, 1) | |
| y = _get_float_field(p_fields, 2) | |
| if x is not None and y is not None: pts.append((x, y)) | |
| if f == 3 and w == 5: | |
| height = b2f(v) | |
| if len(pts) >= 2 and height is not None: | |
| return pts[0], pts[1], height | |
| return None, None, None | |
| def _looks_like_geom(geom_bytes): | |
| geom_fields = parse_proto(geom_bytes) | |
| pts = 0 | |
| has_height = False | |
| for f, w, v in geom_fields: | |
| if f == 1 and w == 2: | |
| p_fields = parse_proto(v) | |
| if _get_float_field(p_fields, 1) is not None and _get_float_field(p_fields, 2) is not None: pts += 1 | |
| elif f == 3 and w == 5: has_height = True | |
| return pts >= 2 and has_height | |
| def _looks_like_span(span_bytes): | |
| span_fields = parse_proto(span_bytes) | |
| has_t = False | |
| has_range = False | |
| for f, w, v in span_fields: | |
| if f in (3, 4) and w == 5: has_t = True | |
| elif f in (1, 2) and w == 0: has_range = True | |
| return has_t and has_range | |
| def _is_item_message(msg_bytes): | |
| fields = parse_proto(msg_bytes) | |
| geom_ok = False | |
| span_ok = 0 | |
| for f, w, v in fields: | |
| if f == 1 and w == 2 and not geom_ok: | |
| geom_ok = _looks_like_geom(v) | |
| elif f == 2 and w == 2: | |
| if _looks_like_span(v): span_ok += 1 | |
| return geom_ok and span_ok > 0 | |
| def _extract_items_from_paragraph(par_bytes): | |
| top = parse_proto(par_bytes) | |
| items = [] | |
| for _, w, v in top: | |
| if w == 2 and _is_item_message(v): items.append(v) | |
| return items | |
| def _extract_item_geom_spans(item_bytes): | |
| fields = parse_proto(item_bytes) | |
| geom_bytes = None | |
| spans_bytes = [] | |
| for f, w, v in fields: | |
| if f == 1 and w == 2: geom_bytes = v | |
| if f == 2 and w == 2: spans_bytes.append(v) | |
| return geom_bytes, spans_bytes | |
| def _extract_span(span_bytes): | |
| span_fields = parse_proto(span_bytes) | |
| start = None; end = None; t0 = None; t1 = None | |
| for f, w, v in span_fields: | |
| if f == 1 and w == 0: start = int(v) | |
| elif f == 2 and w == 0: end = int(v) | |
| elif f == 3 and w == 5: t0 = b2f(v) | |
| elif f == 4 and w == 5: t1 = b2f(v) | |
| return start, end, t0, t1, span_fields | |
| def _normalize_angle_deg(angle_deg): | |
| while angle_deg <= -180.0: angle_deg += 360.0 | |
| while angle_deg > 180.0: angle_deg -= 360.0 | |
| return angle_deg | |
| def _slice_text(full_text, start, end): | |
| if start is None or end is None: return "" | |
| return full_text[start:end] | |
| def _range_min_max(ranges): | |
| if not ranges: return None, None | |
| return min(r[0] for r in ranges), max(r[1] for r in ranges) | |
| def decode_tree(paragraphs_b64, full_text, side, img_w, img_h, want_raw=True): | |
| paragraphs = [] | |
| cursor = 0 | |
| for para_index, b64s in enumerate(paragraphs_b64): | |
| par_bytes = base64.b64decode(b64s) | |
| item_msgs = _extract_items_from_paragraph(par_bytes) | |
| items = [] | |
| para_ranges = [] | |
| for item_index, item_bytes in enumerate(item_msgs): | |
| geom_bytes, spans_bytes = _extract_item_geom_spans(item_bytes) | |
| if geom_bytes is None: continue | |
| p1, p2, height_norm = _get_points_from_geom(geom_bytes) | |
| if p1 is None: continue | |
| x1n, y1n = p1 | |
| x2n, y2n = p2 | |
| dx = (x2n - x1n) * img_w | |
| dy = (y2n - y1n) * img_h | |
| L = math.hypot(dx, dy) | |
| if L <= 1e-12: continue | |
| angle_deg = _normalize_angle_deg(math.degrees(math.atan2(dy, dx))) | |
| item_ranges = [] | |
| for sb in spans_bytes: | |
| start, end, t0, t1, _ = _extract_span(sb) | |
| if start is not None and end is not None: | |
| item_ranges.append((start, end)) | |
| s0, s1 = _range_min_max(item_ranges) | |
| if s0 is not None: para_ranges.append((s0, s1)) | |
| items.append({ | |
| "side": side, "para_index": para_index, "item_index": item_index, | |
| "text": _slice_text(full_text, s0, s1).strip() if s0 is not None else "", | |
| "box": {"left": x1n, "top": y1n, "width": L/img_w, "height": height_norm, "rotation_deg": angle_deg} | |
| }) | |
| p0, p1 = _range_min_max(para_ranges) | |
| paragraphs.append({ | |
| "side": side, "para_index": para_index, | |
| "text": _slice_text(full_text, p0, p1).strip() if p0 is not None else "", | |
| "items": items | |
| }) | |
| return {"side": side, "paragraphs": paragraphs}, [] | |
| def tp_overlay_css(): return "" | |
| def ai_tree_to_tp_html(tree, w, h): return "" | |
| def overlay_css(): return "" | |
| def get_lens_data_from_image(image_path, firebase_url, lang): | |
| ck = _get_firebase_cookie(firebase_url) | |
| with open(image_path, "rb") as f: img_bytes = f.read() | |
| hdr = {"User-Agent": "Mozilla/5.0"} | |
| with httpx.Client(cookies=ck, headers=hdr, follow_redirects=False, timeout=60) as c: | |
| r = c.post("[https://lens.google.com/v3/upload](https://lens.google.com/v3/upload)", files={"encoded_image": ("file.jpg", img_bytes, "image/jpeg")}) | |
| redirect = r.headers["location"] | |
| u = to_translated(redirect, lang=lang) | |
| with httpx.Client(cookies=ck, headers=hdr, timeout=60) as c: | |
| j = c.get(u).text | |
| return json.loads(j[5:] if j.startswith(")]}'") else j) | |
| def _get_firebase_cookie(firebase_url: str): | |
| r = httpx.get(firebase_url, timeout=30) | |
| return r.json() | |
| def main(): | |
| data = get_lens_data_from_image(IMAGE_PATH, FIREBASE_URL, LANG) | |
| img = Image.open(IMAGE_PATH).convert("RGB") | |
| W, H = img.size | |
| out = { | |
| "originalTextFull": data.get("originalTextFull"), | |
| "translatedTextFull": data.get("translatedTextFull"), | |
| } | |
| if DO_ORIGINAL: | |
| original_tree, _ = decode_tree(data.get("originalParagraphs") or [], data.get("originalTextFull") or "", "original", W, H, False) | |
| out["original"] = {"originalTree": original_tree} | |
| if DO_TRANSLATED: | |
| translated_tree, _ = decode_tree(data.get("translatedParagraphs") or [], data.get("translatedTextFull") or "", "translated", W, H, False) | |
| out["translated"] = {"translatedTree": translated_tree} | |
| if DO_AI: | |
| src_text = data.get("originalTextFull") or "" | |
| ai = ai_translate_original_text(src_text, LANG) | |
| patched = patch({"Ai": {"aiTextFull": str(ai.get("aiTextFull") or ""), "aiTree": translated_tree}}, W, H, "", "") | |
| out["Ai"] = {"aiTextFull": str(ai.get("aiTextFull") or ""), "aiTree": patched.get("Ai", {}).get("aiTree", {})} | |
| if WRITE_OUT_JSON: | |
| with open(OUT_JSON, "w", encoding="utf-8") as f: | |
| json.dump(out, f, ensure_ascii=False, indent=2) | |
| if __name__ == "__main__": | |
| main() |