maxza258's picture
Update backend/lens_core.py
636c0e5 verified
Raw
History Blame Contribute Delete
25.6 kB
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()