autotrain/scripts/calibrate.py
xinxin6623 9eb6778a6f feat: web UI enhancements, device calibration, phone control manual, and skills directory
- web_ui.py: major refactor with improved runner, mode selection, progress tracking
- scripts: add device_runtime, device_calibrate, calibration_trace, record_android_flow
- docs: add phone-control-manual, update device-adaptation and windows-migration
- invoice_tool: enhance H5 automation flow and visual fallback
- calibrate.py: expand calibration capabilities
- add skills/ and tests/ directories
- remove obsolete .opencode/skills/invoice-wrapup

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 22:14:56 +08:00

366 lines
14 KiB
Python
Raw 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.

#!/usr/bin/env python3
"""设备校准工具:新手机屏幕适配。
流程:
1. 自动检测屏幕分辨率adb shell wm size
2. 逐个状态截屏用户手动导航到对应页面HTML 上点截图)
3. 对每张截图跑视觉分析(页面类型 + 按钮坐标)
4. 保存设备配置work/device_profile.json
5. invoice_tool.py 读取配置,按比例计算所有坐标
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import device_runtime
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CALIBRATION_DIR = PROJECT_ROOT / "work" / "calibration"
SAMPLE_META_PATH = CALIBRATION_DIR / "trusted_samples.json"
# 旧单设备配置只用于首次迁移;新配置统一按 ADB serial 写入 device_profiles.json。
DEVICE_PROFILE_PATH = PROJECT_ROOT / "work" / "device_profile.json"
VISION_SCRIPT = PROJECT_ROOT / "scripts" / "vision_fallback.py"
def _strip_codeblocks(text: str) -> str:
"""剥掉可能的 markdown 代码块包裹,只保留 JSON 内容"""
text = text.strip()
if text.startswith("```json"):
text = text[7:]
elif text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
return text.strip()
def _parse_json_lenient(text: str) -> dict:
"""lenient JSON parsing, handles markdown codeblocks and trailing garbage"""
text = _strip_codeblocks(text)
# Find first { and last }
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
text = text[start:end+1]
return json.loads(text)
RATIOS = {
"swipe_x": 0.5,
"swipe_start_y": 0.857,
"swipe_end_y": 0.214,
"wake_tap_x": 0.5,
"wake_tap_y": 0.5,
}
SWIPE_SPEED = 2.25
CALIBRATION_STEPS = [
{
"id": "album_qr",
"name": "相册选码",
"desc": "扫码时进入相册选择界面显示QR码缩略图",
"buttons": [],
"expect_page": "other",
"special": "qr_thumbnail",
},
{
"id": "list_page",
"name": "H5 列表页",
"desc": "导航到扫码开票单列表页(显示乘客列表 + 「开具」按钮)",
"buttons": ["开具"],
"expect_page": "list",
},
{
"id": "verify_page",
"name": "身份核验页",
"desc": "点击某乘客的「开具」后进入身份核验页输入身份证后8位",
"buttons": ["核验"],
"expect_page": "verify",
},
{
"id": "invoice_info",
"name": "发票信息页",
"desc": "核验通过后,进入发票信息页(显示抬头/税号/提交按钮)",
"buttons": ["提交"],
"expect_page": "invoice_info",
},
{
"id": "invoice_confirm",
"name": "发票确认页",
"desc": "提交后进入发票确认页(显示确认按钮)",
"buttons": ["确认"],
"expect_page": "invoice_confirm",
},
{
"id": "loading",
"name": "开具中",
"desc": "确认后,发票正在开具中的 loading 页面",
"buttons": [],
"expect_page": "loading",
},
{
"id": "success_page",
"name": "开票成功页",
"desc": "开具完成后进入成功页(显示发票预览/下载 + 继续开票)",
"buttons": ["发票预览/下载", "下载", "继续开票"],
"expect_page": "success",
},
{
"id": "no_ticket",
"name": "无票页",
"desc": "无可开发票客票的页面(发票管理页显示'您没有可开具电子发票的客票'",
"buttons": [],
"expect_page": "no_ticket",
},
]
def get_screen_size() -> tuple[int, int]:
try:
size = str(device_runtime.device_fingerprint().get("physical_size") or "")
if "x" in size:
width, height = size.split("x", 1)
return int(width), int(height)
except (ValueError, device_runtime.DeviceError):
pass
return 0, 0
def capture_screenshot(step_id: str) -> dict:
CALIBRATION_DIR.mkdir(parents=True, exist_ok=True)
path = CALIBRATION_DIR / f"{step_id}.png"
result = device_runtime.run_adb_bytes(["exec-out", "screencap", "-p"], check=False)
if result.returncode != 0:
return {"error": f"adb screencap 失败: {result.stderr.decode('utf-8', 'ignore')}"}
path.write_bytes(result.stdout)
return {"ok": True, "path": str(path.relative_to(PROJECT_ROOT)), "size": len(result.stdout)}
def _call_vision(image_path: Path, prompt: str) -> dict:
result = subprocess.run(
[sys.executable, str(VISION_SCRIPT), "ping", "--image", str(image_path), "--prompt", prompt],
capture_output=True, text=True, check=False, cwd=str(PROJECT_ROOT),
)
if result.returncode != 0:
return {"error": result.stderr.strip()[:500]}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"error": f"非JSON: {result.stdout[:300]}"}
def _screenshot_size(path: Path) -> tuple[int, int]:
data = path.read_bytes()
if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n":
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
return 1260, 2800
def analyze_step(step_id: str) -> dict:
screenshot_path = CALIBRATION_DIR / f"{step_id}.png"
if not screenshot_path.exists():
return {"step_id": step_id, "error": "截图不存在"}
width, height = _screenshot_size(screenshot_path)
step = next((s for s in CALIBRATION_STEPS if s["id"] == step_id), None)
if not step:
return {"step_id": step_id, "error": "未知步骤"}
result = {
"step_id": step_id,
"name": step["name"],
"screenshot": str(screenshot_path.relative_to(PROJECT_ROOT)),
"screen_size": [width, height],
"buttons": {},
}
page_prompt = (
"分析这个手机截图,先写一句话描述,然后判断属于哪种页面类型。"
"注意:"
"- list显示多个乘客的列表页有「开具」按钮"
"- verify身份核验页输入身份证后8位有「核验」按钮"
"- invoice_info发票抬头税号页有「提交」按钮"
"- invoice_confirm发票信息确认页有「确认」按钮"
"- success开票成功页有发票预览/下载,有「继续开票」或「下载」按钮,"
"- no_ticket无可开票客票页显示「您没有可开具电子发票的客票」"
"- loading开具中/加载中页面,"
"- other以上都不是。"
'只返回JSON不要markdown包裹{"page": "list|verify|invoice_info|invoice_confirm|success|no_ticket|loading|other", "summary": "一句话描述"}'
)
page_out = _call_vision(screenshot_path, page_prompt)
page_content = page_out.get("content", "")
try:
page_parsed = _parse_json_lenient(page_content)
result["page_detected"] = page_parsed.get("page", "other")
result["page_summary"] = page_parsed.get("summary", "")
except Exception:
result["page_detected"] = "other"
result["page_summary"] = page_content[:200]
result["page_ok"] = result["page_detected"] == step["expect_page"]
for btn_text in step.get("buttons", []):
btn_prompt = (
f"找到屏幕上文字为「{btn_text}」的可点击按钮。"
f"用0-1000归一化坐标返回按钮中心点x: 0=左, 1000=右; y:0=上,1000=下)。"
f'只返回JSON不要markdown包裹{{"found": true/false, "x": 0-1000, "y": 0-1000}}'
)
btn_out = _call_vision(screenshot_path, btn_prompt)
btn_content = btn_out.get("content", "")
usage = btn_out.get("usage", {})
elapsed = btn_out.get("elapsed_seconds", "?")
try:
btn_parsed = _parse_json_lenient(btn_content)
if btn_parsed.get("found") and btn_parsed.get("x") is not None:
px = int(float(btn_parsed["x"]) * width / 1000)
py = int(float(btn_parsed["y"]) * height / 1000)
result["buttons"][btn_text] = {"found": True, "xy": [px, py], "usage": usage, "elapsed": elapsed}
else:
result["buttons"][btn_text] = {"found": False, "usage": usage, "elapsed": elapsed}
except Exception:
result["buttons"][btn_text] = {"found": False, "error": btn_content[:200]}
if step.get("special") == "qr_thumbnail":
qr_prompt = (
"这是手机相册选择界面。找到黑白QR二维码缩略图的中心。"
'用0-1000归一化坐标返回不要markdown包裹{"found": true/false, "x": 0-1000, "y": 0-1000}'
)
qr_out = _call_vision(screenshot_path, qr_prompt)
try:
qr_parsed = _parse_json_lenient(qr_out.get("content", ""))
if qr_parsed.get("found") and qr_parsed.get("x") is not None:
px = int(float(qr_parsed["x"]) * width / 1000)
py = int(float(qr_parsed["y"]) * height / 1000)
result["qr_thumbnail"] = {"found": True, "xy": [px, py]}
else:
result["qr_thumbnail"] = {"found": False}
except Exception:
result["qr_thumbnail"] = {"found": False}
buttons_ok = all(b.get("found") for b in result["buttons"].values()) if result["buttons"] else True
result["verified"] = result["page_ok"] and buttons_ok
if step.get("special") == "qr_thumbnail":
result["verified"] = result["verified"] and result.get("qr_thumbnail", {}).get("found", False)
return result
def analyze_all() -> dict:
results = {}
for step in CALIBRATION_STEPS:
sid = step["id"]
path = CALIBRATION_DIR / f"{sid}.png"
if path.exists():
results[sid] = analyze_step(sid)
else:
results[sid] = {"step_id": sid, "name": step["name"], "skipped": True}
return results
def load_device_profile() -> dict:
try:
device_runtime.migrate_legacy_profile()
return device_runtime.profile_status().get("profile") or {}
except device_runtime.DeviceError:
return {}
def save_device_profile(calibration: dict | None = None) -> dict:
screen_w, screen_h = get_screen_size()
amplitude = int(screen_h * (RATIOS["swipe_start_y"] - RATIOS["swipe_end_y"]))
duration = max(300, int(amplitude / SWIPE_SPEED))
calibration = calibration or {}
verified = bool(calibration) and all(
bool((calibration.get(step["id"]) or {}).get("verified"))
for step in CALIBRATION_STEPS
)
profile = {
"status": "verified" if verified else "unverified",
"screen": {"width": screen_w, "height": screen_h},
"ratios": RATIOS,
"computed": {
"swipe_x": int(screen_w * RATIOS["swipe_x"]),
"swipe_start_y": int(screen_h * RATIOS["swipe_start_y"]),
"swipe_end_y": int(screen_h * RATIOS["swipe_end_y"]),
"swipe_amplitude": amplitude,
"swipe_duration_ms": duration,
"wake_tap": [int(screen_w * RATIOS["wake_tap_x"]), int(screen_h * RATIOS["wake_tap_y"])],
},
"calibration": calibration,
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
saved = device_runtime.upsert_profile(profile)
return {**profile, "device": saved.get("fingerprint", {})}
def get_device_status() -> dict:
"""给 Web UI 的设备与档案状态;异常转成可展示的 JSON。"""
try:
device_runtime.migrate_legacy_profile()
return device_runtime.profile_status()
except device_runtime.DeviceError as exc:
return {"status": "unavailable", "error": str(exc), "device": {}, "profile": {}}
def get_steps() -> list[dict]:
return [{"id": s["id"], "name": s["name"], "desc": s["desc"],
"buttons": s.get("buttons", []), "expect_page": s.get("expect_page", "")}
for s in CALIBRATION_STEPS]
def get_capture_status() -> dict:
status = {}
for step in CALIBRATION_STEPS:
sid = step["id"]
path = CALIBRATION_DIR / f"{sid}.png"
status[sid] = {"captured": path.exists(), "name": step["name"]}
return status
def _known_step_ids() -> set[str]:
return {str(step["id"]) for step in CALIBRATION_STEPS}
def _load_sample_meta() -> dict:
if not SAMPLE_META_PATH.exists():
return {}
try:
data = json.loads(SAMPLE_META_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def trusted_sample_path(step_id: str) -> Path | None:
"""只返回人工确认过、且与步骤绑定的样图,旧占位图不会被展示。"""
if step_id not in _known_step_ids():
return None
record = _load_sample_meta().get(step_id) or {}
filename = str(record.get("filename") or "")
path = CALIBRATION_DIR / filename
if filename == f"{step_id}_sample.png" and path.exists():
return path
return None
def approve_current_as_sample(step_id: str) -> dict:
"""把当前已截图的页面设为该步骤唯一可信的参考样图。"""
if step_id not in _known_step_ids():
return {"ok": False, "error": "未知校准步骤"}
source = CALIBRATION_DIR / f"{step_id}.png"
if not source.exists():
return {"ok": False, "error": "请先截取当前步骤的正确页面"}
CALIBRATION_DIR.mkdir(parents=True, exist_ok=True)
target = CALIBRATION_DIR / f"{step_id}_sample.png"
shutil.copy2(source, target)
metadata = _load_sample_meta()
metadata[step_id] = {
"filename": target.name,
"approved_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
SAMPLE_META_PATH.write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return {"ok": True, "path": str(target.relative_to(PROJECT_ROOT))}