autotrain/nextgen/autotrain_next/vision.py
2026-07-24 10:37:32 +08:00

103 lines
4.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""独立视觉客户端:复用既有 OpenAI 兼容视觉配置,但不依赖旧 CLI 或旧 ADB。"""
from __future__ import annotations
import base64
import json
import urllib.error
import os
import urllib.request
from pathlib import Path
from .paths import CONFIG
class VisionError(RuntimeError):
pass
def _config() -> dict:
path = CONFIG
if not path.exists():
raise VisionError("缺少 nextgen/config.local.json 的 vision 配置")
vision = json.loads(path.read_text(encoding="utf-8")).get("vision", {})
# 本地 config.local.json 是被 gitignore 的用户私有配置;环境变量仍可覆盖它,便于 CI 或部署注入。
api_key = os.environ.get(str(vision.get("api_key_env", "AUTOTRAIN_NEXT_VISION_API_KEY")), "") or str(vision.get("api_key", ""))
if not all(vision.get(key) for key in ("base_url", "model")) or not api_key:
raise VisionError("vision 配置缺少 base_url/model 或 API key 环境变量")
return {**vision, "api_key": api_key}
def _json(content: str) -> dict:
text = content.strip()
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0]
start, end = text.find("{"), text.rfind("}")
if start < 0 or end < start:
raise VisionError(f"视觉模型未返回 JSON{content[:240]}")
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError as exc:
raise VisionError(f"视觉模型 JSON 无效:{exc}") from exc
def ask(image: Path, prompt: str) -> dict:
cfg = _config()
encoded = base64.b64encode(image.read_bytes()).decode()
payload = {
"model": cfg["model"], "temperature": 0,
"messages": [{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{encoded}"}},
]}],
}
request = urllib.request.Request(
str(cfg["base_url"]).rstrip("/") + "/chat/completions", data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}, method="POST",
)
try:
with urllib.request.urlopen(request, timeout=45) as response:
body = json.loads(response.read())
except urllib.error.HTTPError as exc:
raise VisionError(f"视觉请求失败 HTTP {exc.code}") from exc
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
return _json(content)
def detect_page(image: Path) -> dict:
return ask(image, """这是 12306 App 截图。只返回 JSON
{"page":"list|verify|invoice_info|invoice_confirm|success|email_confirm|mail_sent|no_ticket|loading|other","summary":"不超过30字","scroll_position":"top|middle|bottom|unknown"}。
list 是含脱敏姓名和开具按钮的扫码开票单verify 是身份证后8位核验invoice_info 有提交invoice_confirm 有确认success 是开票成功。""")
def list_rows(image: Path) -> dict:
parsed = ask(image, """识别这张 12306 扫码开票单列表截图。只返回 JSON
{"page_ok":true,"scroll_position":"top|middle|bottom","rows":[{"masked_name":"张*三","id_first":"","id_last":"","button_center":[500,500],"button_text":"开具|已开票|查看"}]}。
button_center 必须是按钮中心的 0-1000 归一化坐标;只列完整可见行;不是列表页则 page_ok=false、rows=[]。""")
rows = parsed.get("rows") or []
valid = []
for row in rows:
point = row.get("button_center") or []
if len(point) != 2:
continue
try:
x, y = float(point[0]) / 1000, float(point[1]) / 1000
except (TypeError, ValueError):
continue
if 0 <= x <= 1 and 0 <= y <= 1:
valid.append({**row, "point": [x, y]})
return {"page_ok": bool(parsed.get("page_ok")), "scroll_position": parsed.get("scroll_position", "unknown"), "rows": valid}
def find_button(image: Path, text: str) -> dict:
parsed = ask(image, f"""在这张 12306 App 截图中找文字为「{text}」的可点击按钮。只返回 JSON
{{"found":true,"center":[500,500]}}
center 必须是 0-1000 归一化坐标;找不到返回 {{"found":false,"center":[]}}。不要猜测。""")
center = parsed.get("center") or []
if not parsed.get("found") or len(center) != 2:
return {"found": False}
try:
point = [float(center[0]) / 1000, float(center[1]) / 1000]
except (TypeError, ValueError):
return {"found": False}
return {"found": all(0 <= value <= 1 for value in point), "point": point}