2026-01-12 14:50:20 +08:00
|
|
|
|
import numpy as np
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
import os
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
|
|
|
|
|
import aiohttp
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import base64
|
2026-01-12 20:11:18 +08:00
|
|
|
|
import cv2
|
2026-01-12 20:39:06 +08:00
|
|
|
|
import uuid
|
2026-01-12 16:12:36 +08:00
|
|
|
|
from openai import AsyncOpenAI, BadRequestError
|
2026-01-12 14:50:20 +08:00
|
|
|
|
from Config.Config import (
|
2026-01-13 08:20:28 +08:00
|
|
|
|
ALY_LLM_API_KEY, VL_MODEL_NAME, VL_MODEL_NAME_AD, TEMP_IMAGE_DIR
|
2026-01-12 14:50:20 +08:00
|
|
|
|
)
|
|
|
|
|
|
from Apps.AiTeJiYiChong.Config.Setting import (
|
|
|
|
|
|
SAFE_EXCLUDE_RATIO, FALLBACK_WIDTH, FALLBACK_HEIGHT,
|
|
|
|
|
|
BOTTOM_SAFE_EXCLUDE_RATIO
|
|
|
|
|
|
)
|
2026-01-12 20:11:18 +08:00
|
|
|
|
from Apps.AiTeJiYiChong import Kit
|
2026-01-13 08:20:28 +08:00
|
|
|
|
from Apps.AiTeJiYiChong.Kit import (
|
2026-01-15 21:29:05 +08:00
|
|
|
|
read_image, save_image, setup_logger
|
2026-01-13 08:20:28 +08:00
|
|
|
|
)
|
2026-01-12 14:50:20 +08:00
|
|
|
|
|
2026-01-15 21:29:05 +08:00
|
|
|
|
logger = setup_logger("ReadImageKit")
|
2026-01-12 14:50:20 +08:00
|
|
|
|
|
2026-01-12 21:33:21 +08:00
|
|
|
|
# 场站过滤黑名单
|
|
|
|
|
|
STATION_EXCLUDED_TITLES = {
|
|
|
|
|
|
"推荐站点", "距离优先", "价格最低", "空闲较多", "筛选", "地图", "列表",
|
|
|
|
|
|
"我的订单", "收藏站点", "优惠券", "站内搜索"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
STATION_BLACKLIST_KEYWORDS = [
|
|
|
|
|
|
"广告", "点击进入", "立即参与", "咨询电话", "活动详情", "导航到", "停车说明"
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-01-12 14:50:20 +08:00
|
|
|
|
|
|
|
|
|
|
class ReadImageKit:
|
2026-01-12 16:12:36 +08:00
|
|
|
|
_client = AsyncOpenAI(
|
2026-01-12 14:50:20 +08:00
|
|
|
|
api_key=ALY_LLM_API_KEY,
|
|
|
|
|
|
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 通用回退设备信息,仅在无法动态获取设备信息时使用
|
|
|
|
|
|
_FALLBACK_DEVICE_INFO = {
|
|
|
|
|
|
"displayWidth": FALLBACK_WIDTH,
|
|
|
|
|
|
"displayHeight": FALLBACK_HEIGHT,
|
|
|
|
|
|
"productName": "generic"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _extract_json(text: str) -> str:
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return "[]"
|
|
|
|
|
|
|
|
|
|
|
|
cleaned = text.strip()
|
|
|
|
|
|
if "```" in cleaned:
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
for line in cleaned.splitlines():
|
|
|
|
|
|
if line.strip().startswith("```"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
lines.append(line)
|
|
|
|
|
|
cleaned = "\n".join(lines).strip()
|
|
|
|
|
|
|
|
|
|
|
|
decoder = json.JSONDecoder()
|
|
|
|
|
|
|
|
|
|
|
|
pos = 0
|
|
|
|
|
|
while pos < len(cleaned):
|
|
|
|
|
|
idx_dict = cleaned.find("{", pos)
|
|
|
|
|
|
idx_list = cleaned.find("[", pos)
|
|
|
|
|
|
|
|
|
|
|
|
candidates = [i for i in (idx_dict, idx_list) if i != -1]
|
|
|
|
|
|
if not candidates:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
start = min(candidates)
|
|
|
|
|
|
snippet = cleaned[start:]
|
|
|
|
|
|
try:
|
|
|
|
|
|
_, end = decoder.raw_decode(snippet)
|
|
|
|
|
|
return snippet[:end]
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
pos = start + 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
return "[]"
|
|
|
|
|
|
|
2026-01-12 15:22:50 +08:00
|
|
|
|
_prompt_detail = (
|
|
|
|
|
|
"仅输出JSON对象(不含任何说明文字),识别充电站详情图片中的以下信息:\n"
|
|
|
|
|
|
"1. station_name: 场站名称;\n"
|
|
|
|
|
|
"2. address: 场站完整地址(通常在定位图标旁)。\n"
|
|
|
|
|
|
"\n"
|
|
|
|
|
|
"特别说明:\n"
|
|
|
|
|
|
"- 严禁输出 Markdown 代码块标签,严格返回纯JSON对象。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-13 11:55:08 +08:00
|
|
|
|
@classmethod
|
|
|
|
|
|
async def detect_ad_popup(cls, image_path: str, device_info=None):
|
|
|
|
|
|
"""
|
|
|
|
|
|
使用 VL 模型检测是否存在弹窗广告,并返回关闭按钮坐标
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not os.path.exists(image_path):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
with open(image_path, "rb") as image_file:
|
|
|
|
|
|
encoded_image = base64.b64encode(image_file.read()).decode("utf-8")
|
|
|
|
|
|
img_input = {"url": f"data:image/jpeg;base64,{encoded_image}"}
|
|
|
|
|
|
|
|
|
|
|
|
# 构建提示词
|
|
|
|
|
|
prompt = (
|
|
|
|
|
|
"请仔细检查这张图片中是否存在**弹窗广告**或**悬浮广告**。\n"
|
|
|
|
|
|
"广告可能有以下几种形式:\n"
|
|
|
|
|
|
"1. **屏幕中央的大型弹窗广告**:通常遮挡了页面内容,内容多为优惠券、活动推广、或“免费停车”类提示。\n"
|
|
|
|
|
|
"2. **悬浮广告**:一个小型的可点击图标,带有明显的关闭按钮。\n"
|
|
|
|
|
|
"3. **底部的横幅广告**:带有明显的关闭按钮。\n\n"
|
|
|
|
|
|
"请返回关闭按钮的中心坐标。\n\n"
|
|
|
|
|
|
"请以纯 JSON 格式输出:\n"
|
|
|
|
|
|
"{\n"
|
|
|
|
|
|
" \"has_ad\": true/false,\n"
|
|
|
|
|
|
" \"ad_type\": \"center\" | \"bottom\" | \"other\",\n"
|
|
|
|
|
|
" \"close_point\": [x, y] // 0-1000 归一化坐标\n"
|
|
|
|
|
|
"}\n"
|
|
|
|
|
|
"如果没有广告,请返回 `{\"has_ad\": false}`。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = await cls._client.chat.completions.create(
|
|
|
|
|
|
model=VL_MODEL_NAME,
|
|
|
|
|
|
timeout=30,
|
|
|
|
|
|
messages=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{"type": "image_url", "image_url": img_input},
|
|
|
|
|
|
{"type": "text", "text": prompt},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
content = resp.choices[0].message.content.strip()
|
|
|
|
|
|
raw_json = cls._extract_json(content)
|
|
|
|
|
|
result = json.loads(raw_json)
|
|
|
|
|
|
|
|
|
|
|
|
if result.get("has_ad") and result.get("close_point"):
|
|
|
|
|
|
norm_point = result["close_point"]
|
|
|
|
|
|
|
|
|
|
|
|
# 转换坐标
|
|
|
|
|
|
if len(norm_point) == 2:
|
|
|
|
|
|
if not device_info:
|
|
|
|
|
|
device_info = cls._FALLBACK_DEVICE_INFO
|
|
|
|
|
|
|
|
|
|
|
|
w = device_info.get("displayWidth", FALLBACK_WIDTH)
|
|
|
|
|
|
h = device_info.get("displayHeight", FALLBACK_HEIGHT)
|
|
|
|
|
|
|
|
|
|
|
|
x = int(norm_point[0] / 1000 * w)
|
|
|
|
|
|
y = int(norm_point[1] / 1000 * h)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"x": x,
|
|
|
|
|
|
"y": y,
|
|
|
|
|
|
"ad_type": result.get("ad_type", "unknown")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Failed to detect ad popup: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-01-12 15:22:50 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _to_minutes(t_str: str) -> int:
|
|
|
|
|
|
"""HH:MM -> 分钟数"""
|
|
|
|
|
|
if not t_str or ":" not in t_str:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
try:
|
|
|
|
|
|
h, m = map(int, t_str.split(":"))
|
|
|
|
|
|
return h * 60 + m
|
|
|
|
|
|
except:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _fmt(t: int) -> str:
|
|
|
|
|
|
"""分钟数 -> HH:MM"""
|
|
|
|
|
|
h = t // 60
|
|
|
|
|
|
m = t % 60
|
|
|
|
|
|
return f"{h:02d}:{m:02d}"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def expand_schedule_to_24h(rows: list) -> list:
|
|
|
|
|
|
"""
|
|
|
|
|
|
将时段列表规整为全天24个整点小时段
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 预处理:转换为分钟区间
|
|
|
|
|
|
intervals = []
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
s = ReadImageKit._to_minutes(r.get("start"))
|
|
|
|
|
|
e = ReadImageKit._to_minutes(r.get("end"))
|
|
|
|
|
|
if e <= s and e != 0: # 处理 00:00-00:00 这种可能
|
|
|
|
|
|
if e == 0: e = 1440
|
|
|
|
|
|
else: continue
|
|
|
|
|
|
if e == 0 and s > 0: e = 1440
|
|
|
|
|
|
|
|
|
|
|
|
s = max(0, s)
|
|
|
|
|
|
e = min(1440, e)
|
|
|
|
|
|
intervals.append({
|
|
|
|
|
|
"s": s, "e": e,
|
|
|
|
|
|
"price": r.get("price")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 排序
|
|
|
|
|
|
intervals.sort(key=lambda x: (x["s"], x["e"]))
|
|
|
|
|
|
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for h in range(24):
|
|
|
|
|
|
hs = h * 60
|
|
|
|
|
|
he = (h + 1) * 60
|
|
|
|
|
|
best_price = None
|
|
|
|
|
|
|
|
|
|
|
|
# 找到覆盖当前小时段的价格
|
|
|
|
|
|
for it in intervals:
|
|
|
|
|
|
# 计算重叠
|
|
|
|
|
|
overlap_s = max(hs, it["s"])
|
|
|
|
|
|
overlap_e = min(he, it["e"])
|
|
|
|
|
|
if overlap_e > overlap_s:
|
|
|
|
|
|
best_price = it["price"]
|
|
|
|
|
|
break # 简单处理,取第一个覆盖的
|
|
|
|
|
|
|
|
|
|
|
|
result.append({
|
|
|
|
|
|
"start": ReadImageKit._fmt(hs),
|
|
|
|
|
|
"end": ReadImageKit._fmt(he),
|
|
|
|
|
|
"price": best_price
|
|
|
|
|
|
})
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
_prompt_price_detail = (
|
|
|
|
|
|
"仅输出JSON对象(不含任何说明文字),识别充电价格详情图片中的分时段电价信息。图片中通常包含“充电时段”、“单价”、“电费”、“服务费”等列。\n"
|
|
|
|
|
|
"请识别出所有的时段行,返回一个列表,每个元素包含:\n"
|
|
|
|
|
|
"1. start: 开始时间 (HH:MM);\n"
|
|
|
|
|
|
"2. end: 结束时间 (HH:MM);\n"
|
|
|
|
|
|
"3. price: 总单价 (数字,如 0.6100);\n"
|
|
|
|
|
|
"4. ele_fee: 电费 (数字);\n"
|
|
|
|
|
|
"5. ser_fee: 服务费 (数字)。\n"
|
|
|
|
|
|
"\n"
|
|
|
|
|
|
"注意:\n"
|
|
|
|
|
|
"- 只需识别“直流桩”或当前选中的桩型下的数据。\n"
|
|
|
|
|
|
"- 严禁输出 Markdown 代码块标签,严格返回纯JSON对象。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
async def get_price_detail_from_image(cls, image_path: str):
|
|
|
|
|
|
"""
|
|
|
|
|
|
使用 VL 模型从三级价格详情页截图中识别分时段电价
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not os.path.exists(image_path):
|
|
|
|
|
|
logger.error(f"Image not found: {image_path}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
with open(image_path, "rb") as image_file:
|
|
|
|
|
|
encoded_image = base64.b64encode(image_file.read()).decode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-01-12 16:12:36 +08:00
|
|
|
|
response = await cls._client.chat.completions.create(
|
2026-01-12 15:22:50 +08:00
|
|
|
|
model=VL_MODEL_NAME,
|
2026-01-12 21:49:19 +08:00
|
|
|
|
timeout=30,
|
2026-01-12 15:22:50 +08:00
|
|
|
|
messages=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{"type": "text", "text": cls._prompt_price_detail},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "image_url",
|
|
|
|
|
|
"image_url": {"url": f"data:image/jpeg;base64,{encoded_image}"},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
max_tokens=2000,
|
|
|
|
|
|
temperature=0.01
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
content = response.choices[0].message.content
|
|
|
|
|
|
logger.info(f"VL Price Detail Response: {content}")
|
|
|
|
|
|
|
|
|
|
|
|
json_str = cls._extract_json(content)
|
|
|
|
|
|
data = json.loads(json_str)
|
|
|
|
|
|
|
|
|
|
|
|
# 兼容性处理:如果返回的是对象且包含列表字段,提取列表
|
|
|
|
|
|
if isinstance(data, dict):
|
2026-01-12 16:12:36 +08:00
|
|
|
|
for key in ["price_list", "prices", "schedule", "data", "items"]:
|
2026-01-12 15:22:50 +08:00
|
|
|
|
if key in data and isinstance(data[key], list):
|
|
|
|
|
|
return data[key]
|
2026-01-12 16:12:36 +08:00
|
|
|
|
|
|
|
|
|
|
# 如果字典本身就是一个价格项(包含 start 和 price),将其包装成列表
|
|
|
|
|
|
if "start" in data and ("price" in data or "total_price" in data):
|
|
|
|
|
|
return [data]
|
2026-01-12 15:22:50 +08:00
|
|
|
|
|
2026-01-12 16:12:36 +08:00
|
|
|
|
# 如果是列表,直接返回
|
|
|
|
|
|
if isinstance(data, list):
|
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
|
# 兜底:确保返回的是列表
|
|
|
|
|
|
return [data] if data else []
|
2026-01-12 15:22:50 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error calling VL model for price detail: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
async def get_station_detail_from_image(cls, image_path: str):
|
|
|
|
|
|
"""
|
|
|
|
|
|
使用 VL 模型从详情页截图中识别地址、名称和电价
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not os.path.exists(image_path):
|
|
|
|
|
|
logger.error(f"Image not found: {image_path}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
with open(image_path, "rb") as image_file:
|
|
|
|
|
|
encoded_image = base64.b64encode(image_file.read()).decode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-01-12 16:12:36 +08:00
|
|
|
|
response = await cls._client.chat.completions.create(
|
2026-01-12 15:22:50 +08:00
|
|
|
|
model=VL_MODEL_NAME,
|
2026-01-12 21:49:19 +08:00
|
|
|
|
timeout=30,
|
2026-01-12 15:22:50 +08:00
|
|
|
|
messages=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": [
|
|
|
|
|
|
{"type": "text", "text": cls._prompt_detail},
|
|
|
|
|
|
{
|
|
|
|
|
|
"type": "image_url",
|
|
|
|
|
|
"image_url": {"url": f"data:image/jpeg;base64,{encoded_image}"},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
max_tokens=1000,
|
|
|
|
|
|
temperature=0.01
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
content = response.choices[0].message.content
|
|
|
|
|
|
logger.info(f"VL Detail Response: {content}")
|
|
|
|
|
|
|
|
|
|
|
|
json_str = cls._extract_json(content)
|
|
|
|
|
|
detail = json.loads(json_str)
|
|
|
|
|
|
|
|
|
|
|
|
return detail
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error calling VL model for detail: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-01-12 20:39:06 +08:00
|
|
|
|
@staticmethod
|
2026-01-13 11:55:08 +08:00
|
|
|
|
async def parse_vl_image(image_path: str, json_metadata, device_info=None):
|
2026-01-12 20:11:18 +08:00
|
|
|
|
"""
|
2026-01-13 11:55:08 +08:00
|
|
|
|
基于 _vl.jpg (带绿框) 和 JSON 元数据进行整页识别 (节约 TOKEN)
|
2026-01-12 20:11:18 +08:00
|
|
|
|
"""
|
2026-01-13 11:55:08 +08:00
|
|
|
|
if not json_metadata or "cards" not in json_metadata:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
2026-01-12 20:11:18 +08:00
|
|
|
|
if not os.path.exists(image_path):
|
2026-01-13 11:55:08 +08:00
|
|
|
|
logger.error(f"Image not found: {image_path}")
|
|
|
|
|
|
return []
|
2026-01-12 20:39:06 +08:00
|
|
|
|
|
|
|
|
|
|
with open(image_path, "rb") as f:
|
|
|
|
|
|
encoded = base64.b64encode(f.read()).decode("utf-8")
|
|
|
|
|
|
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
|
|
|
|
|
|
|
2026-01-13 11:55:08 +08:00
|
|
|
|
cards_meta = json_metadata["cards"]
|
|
|
|
|
|
# 按 id 或 Y 坐标排序,确保顺序一致
|
|
|
|
|
|
cards_meta.sort(key=lambda x: x["rect"][1])
|
|
|
|
|
|
|
2026-01-12 20:39:06 +08:00
|
|
|
|
prompt = (
|
2026-01-13 11:55:08 +08:00
|
|
|
|
"图片中用绿色矩形框标记了若干个充电站卡片区域。\n"
|
|
|
|
|
|
"请按从上到下的顺序,依次识别每个绿色框内的场站信息,并返回一个JSON数组。\n"
|
|
|
|
|
|
"数组中元素的顺序必须与图片中绿色框从上到下的顺序严格一致。\n"
|
|
|
|
|
|
"如果某个框内不是有效的场站卡片(例如是广告或推荐标签),请返回null或空对象,不要跳过顺序。\n"
|
|
|
|
|
|
"\n"
|
|
|
|
|
|
"每个JSON对象包含以下字段:\n"
|
|
|
|
|
|
"1. b_use: 状态标识(1或0)。如果场站名称为灰色或带有“暂停使用”等标签,则为0,否则为1。\n"
|
|
|
|
|
|
"2. station_name: 场站名称;\n"
|
|
|
|
|
|
"3. price: 价格(数字,如 1.2500);\n"
|
|
|
|
|
|
"4. piles: 充电枪列表 [{type: '快充'|'慢充'|'超充', free: 4, total: 4}]。如果图中只显示数字(如 '34/42')而没有明确标明类型,请统一识别为 `type: \"慢充\"`;\n"
|
|
|
|
|
|
"5. parking: 停车费用描述(通常在蓝色'P'图标后,例如 '免费停车三小时');\n"
|
|
|
|
|
|
"6. distance: 距离信息字符串(例如 '2.82km' 或 '90m')。\n"
|
|
|
|
|
|
"\n"
|
|
|
|
|
|
"严格返回纯JSON格式。"
|
2026-01-12 20:39:06 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = await ReadImageKit._client.chat.completions.create(
|
|
|
|
|
|
model=VL_MODEL_NAME,
|
|
|
|
|
|
messages=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": [
|
2026-01-13 11:55:08 +08:00
|
|
|
|
{"type": "image_url", "image_url": img_input},
|
2026-01-12 20:39:06 +08:00
|
|
|
|
{"type": "text", "text": prompt},
|
2026-01-12 14:50:20 +08:00
|
|
|
|
],
|
2026-01-13 11:55:08 +08:00
|
|
|
|
},
|
2026-01-12 14:50:20 +08:00
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-13 11:55:08 +08:00
|
|
|
|
content = resp.choices[0].message.content or ""
|
|
|
|
|
|
raw = ReadImageKit._extract_json(content)
|
|
|
|
|
|
llm_data = json.loads(raw)
|
|
|
|
|
|
|
|
|
|
|
|
final_stations = []
|
|
|
|
|
|
if isinstance(llm_data, list):
|
|
|
|
|
|
# 尝试与 cards_meta 对齐
|
|
|
|
|
|
count = min(len(llm_data), len(cards_meta))
|
|
|
|
|
|
for i in range(count):
|
|
|
|
|
|
item = llm_data[i]
|
|
|
|
|
|
meta = cards_meta[i]
|
|
|
|
|
|
|
|
|
|
|
|
if not item or not isinstance(item, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
name = item.get("station_name")
|
|
|
|
|
|
if not name or name == "未知":
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 注入元数据中的点击坐标
|
|
|
|
|
|
click_pt = meta.get("click_point", [0, 0])
|
|
|
|
|
|
item["uia_center_x"] = click_pt[0]
|
|
|
|
|
|
item["uia_center_y"] = click_pt[1]
|
|
|
|
|
|
item["rect"] = meta.get("rect")
|
|
|
|
|
|
|
|
|
|
|
|
# 转换 bounds 到 0-1000 空间 (用于匹配)
|
|
|
|
|
|
img_w = 1080
|
|
|
|
|
|
img_h = 2400
|
|
|
|
|
|
if device_info:
|
|
|
|
|
|
img_w = device_info.get("displayWidth") or device_info.get("width") or 1080
|
|
|
|
|
|
img_h = device_info.get("displayHeight") or device_info.get("height") or 2400
|
|
|
|
|
|
|
|
|
|
|
|
rect = meta["rect"]
|
|
|
|
|
|
item["bounds"] = [
|
|
|
|
|
|
int(rect[0] * 1000 / img_w),
|
|
|
|
|
|
int(rect[1] * 1000 / img_h),
|
|
|
|
|
|
int(rect[2] * 1000 / img_w),
|
|
|
|
|
|
int(rect[3] * 1000 / img_h)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
final_stations.append(item)
|
|
|
|
|
|
|
|
|
|
|
|
# 后置过滤
|
|
|
|
|
|
if final_stations:
|
|
|
|
|
|
processed_excluded_titles = {str(x).replace(" ", "").strip() for x in STATION_EXCLUDED_TITLES}
|
|
|
|
|
|
filtered = []
|
|
|
|
|
|
for item in final_stations:
|
|
|
|
|
|
name = item.get("station_name")
|
|
|
|
|
|
normalized_name = str(name).replace(" ", "").strip()
|
|
|
|
|
|
if normalized_name in processed_excluded_titles:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if any(kw in normalized_name for kw in STATION_BLACKLIST_KEYWORDS):
|
|
|
|
|
|
continue
|
|
|
|
|
|
filtered.append(item)
|
|
|
|
|
|
final_stations = filtered
|
|
|
|
|
|
|
|
|
|
|
|
return final_stations
|
2026-01-12 14:50:20 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-01-13 11:55:08 +08:00
|
|
|
|
logger.error(f"Error calling VL model for page recognition: {e}")
|
2026-01-12 14:50:20 +08:00
|
|
|
|
return []
|
2026-01-12 21:33:21 +08:00
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
async def find_time_price_button_coordinate(cls, image_path: str, device_info=None):
|
|
|
|
|
|
"""
|
|
|
|
|
|
使用 VL 模型定位详情页中的 '分时价格' 按钮
|
|
|
|
|
|
"""
|
2026-01-12 22:04:41 +08:00
|
|
|
|
if device_info is None:
|
|
|
|
|
|
logger.warning("未提供动态设备信息,使用通用回退配置。")
|
|
|
|
|
|
device_info = cls._FALLBACK_DEVICE_INFO
|
|
|
|
|
|
|
2026-01-12 21:33:21 +08:00
|
|
|
|
if not os.path.exists(image_path):
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
with open(image_path, "rb") as f:
|
|
|
|
|
|
encoded = base64.b64encode(f.read()).decode("utf-8")
|
|
|
|
|
|
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
|
|
|
|
|
|
|
|
|
|
|
|
prompt = (
|
2026-01-12 22:04:41 +08:00
|
|
|
|
"请找到图片中 '分时价格' 或 '查看分时电价' 按钮区域(通常在详情页,可能是一个带有价格图标或箭头的小型矩形区域)。\n"
|
|
|
|
|
|
"返回格式示例:\n"
|
|
|
|
|
|
"{\n"
|
|
|
|
|
|
' "bounds": {"x1": 100, "y1": 200, "x2": 300, "y2": 400}, \n'
|
|
|
|
|
|
' "bounds_norm": {"left": 0.1, "top": 0.2, "right": 0.3, "bottom": 0.4}\n'
|
|
|
|
|
|
"}\n"
|
|
|
|
|
|
"注意:bounds应使用0-1000的归一化坐标空间。\n"
|
|
|
|
|
|
"如果未找到,返回空JSON {}。"
|
2026-01-12 21:33:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = await cls._client.chat.completions.create(
|
|
|
|
|
|
model=VL_MODEL_NAME,
|
2026-01-12 21:49:19 +08:00
|
|
|
|
timeout=30,
|
2026-01-12 21:33:21 +08:00
|
|
|
|
messages=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"role": "user",
|
|
|
|
|
|
"content": [
|
2026-01-12 22:04:41 +08:00
|
|
|
|
{"type": "image_url", "image_url": img_input},
|
|
|
|
|
|
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
|
|
|
|
|
|
{"type": "text", "text": prompt}
|
2026-01-12 21:33:21 +08:00
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
2026-01-12 22:04:41 +08:00
|
|
|
|
max_tokens=200,
|
2026-01-12 21:33:21 +08:00
|
|
|
|
temperature=0.01
|
|
|
|
|
|
)
|
|
|
|
|
|
content = resp.choices[0].message.content.strip()
|
|
|
|
|
|
raw = cls._extract_json(content)
|
|
|
|
|
|
|
2026-01-12 22:04:41 +08:00
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(raw)
|
|
|
|
|
|
if isinstance(data, dict) and (data.get("bounds") or data.get("bounds_norm")):
|
|
|
|
|
|
data = cls._add_center(data, device_info)
|
|
|
|
|
|
# 只返回中心坐标
|
|
|
|
|
|
return {
|
|
|
|
|
|
"uia_center_x": data.get("uia_center_x"),
|
|
|
|
|
|
"uia_center_y": data.get("uia_center_y")
|
|
|
|
|
|
}
|
|
|
|
|
|
return {}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error parsing JSON: {e}")
|
|
|
|
|
|
return {}
|
2026-01-12 21:33:21 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"VL find_time_price_button_coordinate error: {e}")
|
|
|
|
|
|
return {}
|