2026-01-14 07:19:39 +08:00
|
|
|
|
# coding=utf-8
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
# Ensure sys path includes root for imports if not already
|
|
|
|
|
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
if project_root not in sys.path:
|
|
|
|
|
|
sys.path.append(project_root)
|
|
|
|
|
|
|
|
|
|
|
|
from Util.VLMKit import VLMKit
|
2026-01-14 10:28:53 +08:00
|
|
|
|
from Apps.TeLaiDian.Kit import draw_rectangles, detect_cards_cv, setup_logger, read_image
|
2026-01-14 07:31:31 +08:00
|
|
|
|
from Apps.TeLaiDian.Config.Setting import SAFE_EXCLUDE_RATIO, BOTTOM_SAFE_EXCLUDE_RATIO
|
2026-01-14 07:19:39 +08:00
|
|
|
|
|
2026-01-14 07:28:06 +08:00
|
|
|
|
# 初始化日志
|
|
|
|
|
|
logger = setup_logger("ReadImageKit")
|
2026-01-14 07:19:39 +08:00
|
|
|
|
|
|
|
|
|
|
class ReadImageKit:
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.vlm = VLMKit()
|
|
|
|
|
|
|
2026-01-14 10:16:28 +08:00
|
|
|
|
async def find_price_entrance_vlm(self, image_path):
|
|
|
|
|
|
"""
|
|
|
|
|
|
使用 VLM 在详情页寻找价格入口(如:1.1556元/度 的卡片或价格信息按钮)
|
|
|
|
|
|
"""
|
|
|
|
|
|
prompt = """
|
|
|
|
|
|
分析这张充电站详情页截图,找到进入“分时电价详情”的点击入口。
|
|
|
|
|
|
入口特征:
|
|
|
|
|
|
1. 包含价格数字的卡片,例如 "1.1556元/度"。
|
|
|
|
|
|
2. 或者标有 "价格信息"、"电价详情" 字样的按钮。
|
|
|
|
|
|
|
|
|
|
|
|
请判断该入口是否存在,并给出其中心坐标。
|
|
|
|
|
|
输出格式为 JSON:
|
|
|
|
|
|
{
|
|
|
|
|
|
"found": true/false,
|
|
|
|
|
|
"reason": "为什么认为这是入口",
|
|
|
|
|
|
"point": [x, y], // 归一化坐标 [0-1000],例如 [500, 600] 代表屏幕中心偏下
|
|
|
|
|
|
"type": "price_card" / "button"
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
res_text = await self.vlm.analyze_image(image_path, prompt)
|
|
|
|
|
|
json_str = self.vlm.extract_json(res_text)
|
|
|
|
|
|
data = json.loads(json_str)
|
2026-01-14 10:40:29 +08:00
|
|
|
|
|
|
|
|
|
|
# 保存诊断图片
|
|
|
|
|
|
if data.get("found") and data.get("point"):
|
|
|
|
|
|
p = data["point"]
|
|
|
|
|
|
# 转换坐标
|
|
|
|
|
|
img = read_image(image_path)
|
|
|
|
|
|
if img is not None:
|
|
|
|
|
|
h, w = img.shape[:2]
|
|
|
|
|
|
actual_p = [int(p[0] * w / 1000), int(p[1] * h / 1000)]
|
|
|
|
|
|
# 生成虚拟 bbox
|
|
|
|
|
|
bbox = [actual_p[0]-50, actual_p[1]-30, actual_p[0]+50, actual_p[1]+30]
|
|
|
|
|
|
draw_rectangles(image_path, bboxes=[bbox], click_points=[actual_p])
|
|
|
|
|
|
logger.info(f"已生成价格入口诊断图片: {image_path.replace('.jpg', '_vl.jpg')}")
|
|
|
|
|
|
|
2026-01-14 10:16:28 +08:00
|
|
|
|
return data
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"VLM 寻找价格入口失败: {e}")
|
|
|
|
|
|
return {"found": False}
|
|
|
|
|
|
|
2026-01-14 10:19:20 +08:00
|
|
|
|
async def find_close_button_vlm(self, image_path):
|
|
|
|
|
|
"""
|
|
|
|
|
|
使用 VLM 在页面上寻找关闭按钮(用于清理广告弹窗)
|
|
|
|
|
|
"""
|
|
|
|
|
|
prompt = """
|
|
|
|
|
|
分析这张截图,判断是否存在弹窗广告或遮罩层。
|
|
|
|
|
|
如果存在,请找到关闭按钮(通常是圆圈里的 X,或者写着“跳过”、“关闭”的按钮)。
|
|
|
|
|
|
|
2026-01-14 10:40:29 +08:00
|
|
|
|
**重要警告**:
|
|
|
|
|
|
1. 严禁选择屏幕最右上角的按钮(微信小程序的“胶囊按钮”,包含三个点和圆圈)。
|
|
|
|
|
|
2. 广告关闭按钮通常在弹窗的边缘,或者是页面中心大弹窗的某个角落。
|
|
|
|
|
|
|
2026-01-14 10:19:20 +08:00
|
|
|
|
输出格式为 JSON:
|
|
|
|
|
|
{
|
|
|
|
|
|
"has_ad": true/false,
|
|
|
|
|
|
"reason": "简单描述发现的弹窗",
|
|
|
|
|
|
"close_point": [x, y] // 归一化坐标 [0-1000],如果不存在则为 null
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
res_text = await self.vlm.analyze_image(image_path, prompt)
|
|
|
|
|
|
json_str = self.vlm.extract_json(res_text)
|
|
|
|
|
|
data = json.loads(json_str)
|
2026-01-14 10:40:29 +08:00
|
|
|
|
|
|
|
|
|
|
# 保存诊断图片
|
|
|
|
|
|
if data.get("has_ad") and data.get("close_point"):
|
|
|
|
|
|
p = data["close_point"]
|
|
|
|
|
|
# 转换坐标
|
|
|
|
|
|
img = read_image(image_path)
|
|
|
|
|
|
if img is not None:
|
|
|
|
|
|
h, w = img.shape[:2]
|
|
|
|
|
|
actual_p = [int(p[0] * w / 1000), int(p[1] * h / 1000)]
|
|
|
|
|
|
# 生成虚拟 bbox
|
|
|
|
|
|
bbox = [actual_p[0]-50, actual_p[1]-30, actual_p[0]+50, actual_p[1]+30]
|
|
|
|
|
|
draw_rectangles(image_path, bboxes=[bbox], click_points=[actual_p])
|
|
|
|
|
|
logger.info(f"已生成广告关闭诊断图片: {image_path.replace('.jpg', '_vl.jpg')}")
|
|
|
|
|
|
|
2026-01-14 10:19:20 +08:00
|
|
|
|
return data
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"VLM 寻找关闭按钮失败: {e}")
|
|
|
|
|
|
return {"has_ad": False}
|
|
|
|
|
|
|
2026-01-14 10:40:29 +08:00
|
|
|
|
async def check_wrong_page_vlm(self, image_path):
|
|
|
|
|
|
"""
|
|
|
|
|
|
检查是否误触进入了错误的页面(如:新人福利、我的卡券、活动页等)
|
|
|
|
|
|
"""
|
|
|
|
|
|
prompt = """
|
|
|
|
|
|
分析这张截图,判断这是否是一个真实的“充电站详情页”。
|
|
|
|
|
|
|
|
|
|
|
|
**识别准则**:
|
|
|
|
|
|
1. 真正的“详情页”必须包含:充电站的具体名称、电价列表、终端状态(空闲/占用)等信息。
|
|
|
|
|
|
2. 如果页面标题是“新人福利专区”、“活动规则”、“我的卡券”、“领券中心”或类似的营销活动页,则判定为错误页面。
|
|
|
|
|
|
|
|
|
|
|
|
输出格式为 JSON:
|
|
|
|
|
|
{
|
|
|
|
|
|
"is_detail_page": true/false,
|
|
|
|
|
|
"page_type": "detail" / "marketing" / "coupons" / "other",
|
|
|
|
|
|
"reason": "判断依据"
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
res_text = await self.vlm.analyze_image(image_path, prompt)
|
|
|
|
|
|
json_str = self.vlm.extract_json(res_text)
|
|
|
|
|
|
data = json.loads(json_str)
|
|
|
|
|
|
return data
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"VLM 检查页面类型失败: {e}")
|
|
|
|
|
|
return {"is_detail_page": True} # 默认认为是详情页,避免死循环
|
|
|
|
|
|
|
2026-01-14 07:19:39 +08:00
|
|
|
|
async def analyze_detail_price(self, image_path):
|
|
|
|
|
|
"""
|
2026-01-14 09:56:04 +08:00
|
|
|
|
分析详情页截图,提取电价信息,包括优惠价、PLUS价和挂牌价
|
2026-01-14 07:19:39 +08:00
|
|
|
|
"""
|
|
|
|
|
|
prompt = """
|
2026-01-14 09:56:04 +08:00
|
|
|
|
分析这张充电站价格详情页截图,提取**分时电价表**。
|
|
|
|
|
|
对于每个时段,请识别并提取以下所有价格信息(如果存在):
|
|
|
|
|
|
1. 优惠价 (通常是红色或加粗的大字,作为默认 price)
|
|
|
|
|
|
2. PLUS会员价 (标有 "PLUS" 标签的价格)
|
|
|
|
|
|
3. 挂牌价 (标有 "挂牌价" 标签的价格)
|
|
|
|
|
|
4. 电费 (Base electricity price)
|
|
|
|
|
|
5. 服务费 (Service fee)
|
2026-01-14 07:19:39 +08:00
|
|
|
|
|
|
|
|
|
|
请提取每个时段的:
|
2026-01-14 09:56:04 +08:00
|
|
|
|
- start: 开始时间 (HH:MM)
|
|
|
|
|
|
- end: 结束时间 (HH:MM)
|
|
|
|
|
|
- price: 优惠价 (元/度)
|
|
|
|
|
|
- plus_price: PLUS会员价 (元/度)
|
|
|
|
|
|
- market_price: 挂牌价 (元/度)
|
|
|
|
|
|
- elec_price: 电费 (元/度)
|
|
|
|
|
|
- service_price: 服务费 (元/度)
|
2026-01-14 07:19:39 +08:00
|
|
|
|
|
|
|
|
|
|
输出格式为 JSON 数组:
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
2026-01-14 09:56:04 +08:00
|
|
|
|
"start": "16:00",
|
|
|
|
|
|
"end": "21:00",
|
|
|
|
|
|
"price": 1.3435,
|
|
|
|
|
|
"plus_price": 1.3035,
|
|
|
|
|
|
"market_price": 1.4435,
|
|
|
|
|
|
"elec_price": 0.9435,
|
|
|
|
|
|
"service_price": 0.4000
|
2026-01-14 07:19:39 +08:00
|
|
|
|
},
|
|
|
|
|
|
...
|
|
|
|
|
|
]
|
2026-01-14 09:56:04 +08:00
|
|
|
|
注意:
|
|
|
|
|
|
- 如果某个字段缺失,请设为 null。
|
|
|
|
|
|
- 确保 price 包含电费和服务费的总和。
|
|
|
|
|
|
- 如果无法识别任何价格信息,请返回空数组 []。
|
2026-01-14 07:19:39 +08:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
res_text = await self.vlm.analyze_image(image_path, prompt)
|
|
|
|
|
|
logger.info(f"VLM Price Analysis Result for {os.path.basename(image_path)}: {res_text[:200]}...")
|
|
|
|
|
|
|
|
|
|
|
|
json_str = self.vlm.extract_json(res_text)
|
|
|
|
|
|
prices = json.loads(json_str)
|
|
|
|
|
|
|
|
|
|
|
|
normalized_prices = []
|
|
|
|
|
|
if isinstance(prices, list):
|
|
|
|
|
|
for p in prices:
|
|
|
|
|
|
new_p = p.copy()
|
|
|
|
|
|
if 'time_range' in p and ('start' not in p or 'end' not in p):
|
|
|
|
|
|
tr = p['time_range'].replace('~', '-').replace(' ', '')
|
|
|
|
|
|
parts = tr.split('-')
|
|
|
|
|
|
if len(parts) >= 2:
|
|
|
|
|
|
new_p['start'] = parts[0]
|
|
|
|
|
|
new_p['end'] = parts[1]
|
|
|
|
|
|
|
|
|
|
|
|
if 'price' not in p:
|
|
|
|
|
|
if 'total_price' in p:
|
|
|
|
|
|
new_p['price'] = p['total_price']
|
|
|
|
|
|
elif 'elec_price' in p and 'service_price' in p:
|
|
|
|
|
|
try:
|
|
|
|
|
|
new_p['price'] = float(p['elec_price']) + float(p['service_price'])
|
|
|
|
|
|
except:
|
|
|
|
|
|
pass
|
|
|
|
|
|
normalized_prices.append(new_p)
|
|
|
|
|
|
return normalized_prices
|
|
|
|
|
|
return []
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"分析电价详情失败: {e}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
2026-01-14 07:55:31 +08:00
|
|
|
|
async def analyze_detail_basic_info(self, image_path):
|
|
|
|
|
|
"""
|
|
|
|
|
|
分析详情页首屏截图,提取场站名称和精确地址
|
|
|
|
|
|
"""
|
|
|
|
|
|
prompt = """
|
|
|
|
|
|
分析这张充电站详情页首屏截图,提取:
|
|
|
|
|
|
1. 场站名称 (通常在页面中部,大字体)
|
|
|
|
|
|
2. 详细地址 (通常在名称下方或页面下半部分,伴有地址图标)
|
|
|
|
|
|
|
|
|
|
|
|
输出格式为 JSON:
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "xxx充电站",
|
|
|
|
|
|
"address": "xxx省xxx市xxx区xxx路xxx号"
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
res_text = await self.vlm.analyze_image(image_path, prompt)
|
|
|
|
|
|
json_str = self.vlm.extract_json(res_text)
|
|
|
|
|
|
return json.loads(json_str)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"分析详情页基础信息失败: {e}")
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
2026-01-14 07:19:39 +08:00
|
|
|
|
async def analyze_station_list(self, image_path):
|
|
|
|
|
|
"""
|
|
|
|
|
|
分析场站列表页图片,提取场站位置和基本信息
|
|
|
|
|
|
"""
|
|
|
|
|
|
cv_bboxes = detect_cards_cv(image_path, top_ratio=SAFE_EXCLUDE_RATIO, bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO)
|
|
|
|
|
|
|
|
|
|
|
|
if cv_bboxes:
|
2026-01-14 10:40:29 +08:00
|
|
|
|
draw_rectangles(image_path, bboxes=cv_bboxes)
|
2026-01-14 07:19:39 +08:00
|
|
|
|
prompt = f"""
|
|
|
|
|
|
图片中已经用绿色矩形框标记了 {len(cv_bboxes)} 个可能的充电站卡片。
|
|
|
|
|
|
请按从上到下的顺序,识别每个绿色框内的场站信息。
|
|
|
|
|
|
|
|
|
|
|
|
输出格式为 JSON 数组,长度必须为 {len(cv_bboxes)}。
|
|
|
|
|
|
每个对象包含:
|
|
|
|
|
|
- "name": 场站名称
|
|
|
|
|
|
- "address": 场站地址
|
|
|
|
|
|
- "is_valid": true/false (是否为真实的场站卡片)
|
|
|
|
|
|
"""
|
|
|
|
|
|
else:
|
|
|
|
|
|
prompt = """
|
|
|
|
|
|
分析这张充电站列表截图,提取所有充电站卡片信息。
|
2026-01-14 10:24:02 +08:00
|
|
|
|
|
|
|
|
|
|
**重要限制**:
|
|
|
|
|
|
1. 忽略页面上半部分(坐标 y < 500)的所有内容,包括顶部的搜索框、广告 Banner 和“PLUS会员/我的卡券”等图标。
|
|
|
|
|
|
2. 仅提取下方重复出现的、包含“充电站”名称的矩形卡片。
|
|
|
|
|
|
|
2026-01-14 07:19:39 +08:00
|
|
|
|
输出格式为 JSON 数组,每个对象包含:
|
|
|
|
|
|
- "name": 场站名称
|
|
|
|
|
|
- "address": 场站地址
|
|
|
|
|
|
- "point": 场站卡片的中心点击坐标 [x, y]
|
|
|
|
|
|
- "bbox": 场站卡片的边界框 [x1, y1, x2, y2]
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
res_text = await self.vlm.analyze_image(image_path, prompt)
|
|
|
|
|
|
json_str = self.vlm.extract_json(res_text)
|
|
|
|
|
|
vlm_results = json.loads(json_str)
|
|
|
|
|
|
|
|
|
|
|
|
final_stations = []
|
|
|
|
|
|
if cv_bboxes and isinstance(vlm_results, list):
|
2026-01-14 10:43:46 +08:00
|
|
|
|
img = read_image(image_path)
|
|
|
|
|
|
h, w = img.shape[:2] if img is not None else (2400, 1080)
|
|
|
|
|
|
y_threshold = h * 0.58
|
|
|
|
|
|
|
2026-01-14 07:19:39 +08:00
|
|
|
|
for i, res in enumerate(vlm_results):
|
|
|
|
|
|
if i < len(cv_bboxes):
|
|
|
|
|
|
bbox = cv_bboxes[i]
|
2026-01-14 10:43:46 +08:00
|
|
|
|
# 物理坐标硬过滤:即使 CV 识别到了,y 坐标在 58% 以上的也统统干掉
|
|
|
|
|
|
center_y = (bbox[1] + bbox[3]) // 2
|
|
|
|
|
|
if center_y < y_threshold:
|
|
|
|
|
|
logger.warning(f"CV 误报过滤 (y={center_y} < {y_threshold}): 可能是顶部营销卡片")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-01-14 07:19:39 +08:00
|
|
|
|
if res and (res.get("is_valid") is True or (res.get("name") and res.get("is_valid") is not False)):
|
|
|
|
|
|
final_stations.append({
|
|
|
|
|
|
"name": res.get("name"),
|
|
|
|
|
|
"address": res.get("address"),
|
|
|
|
|
|
"point": [(bbox[0] + bbox[2]) // 2, (bbox[1] + bbox[3]) // 2],
|
|
|
|
|
|
"bbox": bbox
|
|
|
|
|
|
})
|
|
|
|
|
|
elif not cv_bboxes:
|
2026-01-14 10:24:02 +08:00
|
|
|
|
vlm_list = vlm_results if isinstance(vlm_results, list) else []
|
|
|
|
|
|
w, h = 0, 0
|
|
|
|
|
|
for res in vlm_list:
|
|
|
|
|
|
p = res.get("point")
|
|
|
|
|
|
if p and len(p) == 2:
|
2026-01-14 10:28:53 +08:00
|
|
|
|
# 1. 坐标转换逻辑
|
|
|
|
|
|
actual_p = p
|
2026-01-14 10:24:02 +08:00
|
|
|
|
if p[0] <= 1000 and p[1] <= 1000:
|
|
|
|
|
|
if w == 0:
|
2026-01-14 10:28:53 +08:00
|
|
|
|
img = read_image(image_path)
|
2026-01-14 10:24:02 +08:00
|
|
|
|
if img is not None:
|
|
|
|
|
|
h, w = img.shape[:2]
|
|
|
|
|
|
else:
|
|
|
|
|
|
w, h = 1080, 2400 # 兜底
|
2026-01-14 10:28:53 +08:00
|
|
|
|
actual_p = [int(p[0] * w / 1000), int(p[1] * h / 1000)]
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 坐标安全过滤:忽略页面上半部分的误点击(通常是 Logo 或广告)
|
|
|
|
|
|
# 即使 VLM 没听话,我们也在这里硬性过滤
|
2026-01-14 10:40:29 +08:00
|
|
|
|
# 根据主屏幕截图,顶部广告区和功能图标区占用了约 55% 的空间
|
|
|
|
|
|
y_threshold = h * 0.58 if h > 0 else 1400
|
2026-01-14 10:28:53 +08:00
|
|
|
|
if actual_p[1] < y_threshold:
|
|
|
|
|
|
logger.warning(f"过滤掉可能的误点击点 (y={actual_p[1]} < {y_threshold}): {res.get('name')}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
res["point"] = actual_p
|
2026-01-14 10:24:02 +08:00
|
|
|
|
final_stations.append(res)
|
2026-01-14 10:40:29 +08:00
|
|
|
|
|
|
|
|
|
|
# 保存诊断图片 (_vl.jpg, _flag.jpg)
|
|
|
|
|
|
if final_stations:
|
|
|
|
|
|
debug_bboxes = []
|
|
|
|
|
|
debug_points = []
|
|
|
|
|
|
for s in final_stations:
|
|
|
|
|
|
if s.get("bbox"):
|
|
|
|
|
|
debug_bboxes.append(s["bbox"])
|
|
|
|
|
|
if s.get("point"):
|
|
|
|
|
|
debug_points.append(s["point"])
|
|
|
|
|
|
|
|
|
|
|
|
# 如果没有 bbox,根据 point 生成一个虚拟 bbox 供可视化
|
|
|
|
|
|
if not debug_bboxes and debug_points:
|
|
|
|
|
|
for p in debug_points:
|
|
|
|
|
|
debug_bboxes.append([p[0]-50, p[1]-30, p[0]+50, p[1]+30])
|
|
|
|
|
|
|
|
|
|
|
|
if debug_bboxes or debug_points:
|
|
|
|
|
|
draw_rectangles(image_path, bboxes=debug_bboxes, click_points=debug_points)
|
|
|
|
|
|
logger.info(f"已生成诊断图片: {image_path.replace('.jpg', '_vl.jpg')} 和 _flag.jpg")
|
2026-01-14 07:19:39 +08:00
|
|
|
|
|
|
|
|
|
|
return final_stations
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"分析列表页失败: {e}")
|
|
|
|
|
|
return []
|