This commit is contained in:
HuangHai
2026-01-12 16:53:43 +08:00
parent 4cef36dc96
commit f263c133b8
7 changed files with 441 additions and 758 deletions

View File

@@ -7,7 +7,7 @@ import json
import aiohttp
import logging
import base64
from openai import OpenAI, BadRequestError
from openai import AsyncOpenAI, BadRequestError
from Config.Config import (
ALY_LLM_API_KEY, VL_MODEL_NAME, VL_MODEL_NAME_AD
)
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
class ReadImageKit:
_client = OpenAI(
_client = AsyncOpenAI(
api_key=ALY_LLM_API_KEY,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)
@@ -207,11 +207,18 @@ class ReadImageKit:
return obj
@staticmethod
async def find_all_time_button_coordinate(image_url: str, device_info: dict = None) -> dict:
async def find_all_time_button_coordinate(image_url_or_path: str, device_info: dict = None) -> dict:
if device_info is None:
logger.warning("未提供动态设备信息,使用通用回退配置。")
device_info = ReadImageKit._FALLBACK_DEVICE_INFO
if os.path.exists(image_url_or_path):
with open(image_url_or_path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
else:
img_input = {"url": image_url_or_path}
prompt = (
"仅输出JSON对象(不含任何说明文字),请找到图片中带有“全部时段”字样的按钮区域(通常在价格表下方,是一个带有右箭头的文字按钮)。\n"
"返回格式示例:\n"
@@ -223,27 +230,21 @@ class ReadImageKit:
"如果未找到,返回空JSON {}。"
)
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None,
lambda: ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": image_url
},
},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": prompt},
],
},
],
)
resp = await ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": img_input,
},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": prompt},
],
},
],
)
content = resp.choices[0].message.content or ""
@@ -265,11 +266,18 @@ class ReadImageKit:
return {}
@staticmethod
async def find_station_coordinate_first_page(image_url: str, station_name: str, device_info: dict = None) -> dict:
async def find_station_coordinate_first_page(image_url_or_path: str, station_name: str, device_info: dict = None) -> dict:
if device_info is None:
logger.warning("未提供动态设备信息,使用通用回退配置。")
device_info = ReadImageKit._FALLBACK_DEVICE_INFO
if os.path.exists(image_url_or_path):
with open(image_url_or_path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
else:
img_input = {"url": image_url_or_path}
prompt = (
"仅输出JSON对象(不含任何说明文字)。"
"请在图片中找到场站名称完全匹配“" + str(station_name) + "”的那一行/卡片,"
@@ -280,22 +288,18 @@ class ReadImageKit:
"注意:bounds使用0-1000归一化坐标空间;如果找不到,返回空JSON {}。"
)
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None,
lambda: ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": prompt},
],
},
],
)
resp = await ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": img_input},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": prompt},
],
},
],
)
content = resp.choices[0].message.content or ""
@@ -316,32 +320,33 @@ class ReadImageKit:
return {}
@staticmethod
async def parse_first_level_image_url(image_url: str, device_info: dict = None) -> list:
async def parse_first_level_image_url(image_url_or_path: str, device_info: dict = None) -> list:
if device_info is None:
logger.warning("未提供动态设备信息,使用通用回退配置。")
device_info = ReadImageKit._FALLBACK_DEVICE_INFO
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None,
lambda: ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": image_url
},
},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": ReadImageKit._prompt},
],
},
],
)
if os.path.exists(image_url_or_path):
with open(image_url_or_path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
else:
img_input = {"url": image_url_or_path}
resp = await ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": img_input,
},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": ReadImageKit._prompt},
],
},
],
)
content = resp.choices[0].message.content or ""
@@ -397,7 +402,7 @@ class ReadImageKit:
if name:
idxs.append(i)
tasks.append(asyncio.create_task(
ReadImageKit.find_station_coordinate_first_page(image_url, name, device_info)
ReadImageKit.find_station_coordinate_first_page(image_url_or_path, name, device_info)
))
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -421,13 +426,20 @@ class ReadImageKit:
return await ReadImageKit.get_stations_hybrid(image_path, uploader, cdn_domain)
@staticmethod
async def parse_vl_image(vl_image_url, json_metadata, device_info=None):
async def parse_vl_image(image_url_or_path: str, json_metadata, device_info=None):
"""
基于 _vl.jpg (带绿框) 和 JSON 元数据进行识别
"""
if not json_metadata or "cards" not in json_metadata:
return []
if os.path.exists(image_url_or_path):
with open(image_url_or_path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
else:
img_input = {"url": image_url_or_path}
cards_meta = json_metadata["cards"]
# 按 id 或 Y 坐标排序,确保顺序一致 (Kit 生成时已经是 top-down)
cards_meta.sort(key=lambda x: x["rect"][1])
@@ -450,21 +462,17 @@ class ReadImageKit:
"严格返回纯JSON格式。"
)
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None,
lambda: ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": vl_image_url}},
{"type": "text", "text": prompt},
],
},
],
)
resp = await ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": img_input},
{"type": "text", "text": prompt},
],
},
],
)
content = resp.choices[0].message.content or ""
@@ -769,25 +777,21 @@ class ReadImageKit:
"""
对切片进行小图 OCR 识别,提取场站详细信息
"""
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None,
lambda: ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "请识别图片中的充电站信息,并以 JSON 格式输出:{\"station_name\": \"...\", \"price\": \"...\", \"piles\": \"空闲数/总数\"}。只输出 JSON,不要有其他文字。"
},
{"type": "image_url", "image_url": {"url": patch_url}}
]
}
],
max_tokens=200
)
resp = await ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "请识别图片中的充电站信息,并以 JSON 格式输出:{\"station_name\": \"...\", \"price\": \"...\", \"piles\": \"空闲数/总数\"}。只输出 JSON,不要有其他文字。"
},
{"type": "image_url", "image_url": {"url": patch_url}}
]
}
],
max_tokens=200
)
content = resp.choices[0].message.content.strip()
# 尝试解析 JSON
@@ -803,12 +807,12 @@ class ReadImageKit:
return {"station_name": "未知"}
@staticmethod
async def detect_ad(image_url: str, device_info: dict = None) -> dict:
async def detect_ad(image_url_or_path: str, device_info: dict = None) -> dict:
"""
检测图片中是否存在广告弹窗,并定位关闭按钮坐标
参数:
image_url: 图片地址
image_url_or_path: 图片地址或本地路径
device_info: 设备信息
返回:
@@ -822,34 +826,37 @@ class ReadImageKit:
logger.warning("未提供动态设备信息,使用通用回退配置。")
device_info = ReadImageKit._FALLBACK_DEVICE_INFO
if os.path.exists(image_url_or_path):
with open(image_url_or_path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
else:
img_input = {"url": image_url_or_path}
prompt = (
"分析图片中是否存在覆盖在主界面上的广告弹窗(Popup Ad)。"
"如果存在,请找到关闭该弹窗的按钮(通常是一个带有 'X' 的图标,可能在弹窗的右上角、右下角或正下方)。"
"如果存在,请找到关闭该弹窗的按钮(通常是一个带有 'X' 的图标,可能在弹窗的右上角、右下角 or 正下方)。"
"仅输出JSON对象(不含任何说明文字),包含以下字段:"
"1. has_ad: 布尔值,是否存在广告弹窗;"
"2. close_button_bounds: 关闭按钮的像素坐标或归一化坐标(0-1000) {x1,y1,x2,y2}。如果不存在广告则为 null。"
"注意:只需识别最明显的那个关闭按钮。严格返回纯JSON。"
)
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None,
lambda: ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME_AD, # 使用更强的视觉模型
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": image_url},
},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": prompt},
],
},
],
)
resp = await ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME_AD, # 使用更强的视觉模型
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": img_input,
},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": prompt},
],
},
],
)
content = resp.choices[0].message.content or ""
@@ -889,32 +896,48 @@ class ReadImageKit:
return None
@staticmethod
async def parse_price_schedule(station_name: str, image_url: str, device_info: dict = None) -> list:
async def get_image_md5(image_url_or_path: str) -> str:
"""
解析价格时段表(整图),提取每一行的时间区间与费用信息
参数:
image_url: 图片地址(包含“当前时段电费/全部时段电费”弹窗或列表)
device_info: 设备信息,用于提升视觉理解一致性(可选)
返回:
列表,每个元素为:
{
"start": "HH:MM", 开始时间
"end": "HH:MM", 结束时间
"price_kwh": float|None, 总价或站点价(元/度)
"electric_fee_kwh": float|None, 电费(元/度)
"service_fee_kwh": float|None 服务费(元/度)
}
说明:
- 使用 qwen3-vl-flash 进行视觉解析,提示词约束输出为纯 JSON 数组
- 若某项缺失则返回 None(保持结构统一)
计算图片的 MD5(用于内容去重),支持 URL 或 本地路径
"""
if not image_url_or_path:
return ""
# 如果是本地文件
if os.path.exists(image_url_or_path):
from Apps.XinDianTu.Kit import get_file_md5
return get_file_md5(image_url_or_path)
# 如果是 URL
try:
async with aiohttp.ClientSession() as session:
async with session.get(image_url_or_path, timeout=10) as resp:
if resp.status != 200:
return ""
content = await resp.read()
return hashlib.md5(content).hexdigest()
except Exception as e:
logger.error(f"MD5 获取失败 ({image_url_or_path}): {e}")
return ""
@staticmethod
async def parse_price_schedule(station_name: str, image_url_or_path: str, device_info: dict = None) -> list:
"""
解析价格时段表,支持 URL 或 本地路径
"""
if device_info is None:
logger.warning("未提供动态设备信息,使用通用回退配置。")
device_info = ReadImageKit._FALLBACK_DEVICE_INFO
# 视觉解析提示词:约束输出字段与格式,避免模型输出说明文字
# 处理图片输入 (URL or Local Path)
if os.path.exists(image_url_or_path):
with open(image_url_or_path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
else:
img_input = {"url": image_url_or_path}
# 视觉解析提示词
prompt = (
"仅输出JSON数组(不含任何说明文字)。识别图片中所有时段的价格信息,返回每一行:"
"1) start: 开始时间(HH:MM),2) end: 结束时间(HH:MM),"
@@ -925,205 +948,46 @@ class ReadImageKit:
"注意:如果某行价格信息为空或表示同上,请尝试复用上一行的价格信息。"
)
loop = asyncio.get_event_loop()
def _do_request(url_val):
return ReadImageKit._client.chat.completions.create(
try:
resp = await ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": url_val},
},
{"type": "image_url", "image_url": img_input},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": prompt},
],
},
],
)
try:
# 在线程池中同步调用 OpenAI 兼容接口,避免阻塞事件循环
resp = await loop.run_in_executor(None, lambda: _do_request(image_url))
except BadRequestError as e:
# 尝试捕获 DataInspection 错误并进行 Base64 回退
err_code = getattr(e, 'code', '') or ''
if not err_code and hasattr(e, 'body') and isinstance(e.body, dict):
err_code = e.body.get('code', '')
# 如果是媒体格式或数据检查错误
if 'InvalidParameter.DataInspection' in str(err_code) or 'media format' in str(e).lower():
logger.warning(f"Image URL rejected ({err_code}). Attempting Base64 fallback: {image_url}")
base64_str = await ReadImageKit._download_as_base64(image_url)
if base64_str:
# 简单推断格式,默认 jpeg
ext = "jpeg"
lower_url = image_url.lower()
if ".png" in lower_url:
ext = "png"
elif ".webp" in lower_url:
ext = "webp"
elif ".jpg" in lower_url or ".jpeg" in lower_url:
ext = "jpeg"
data_uri = f"data:image/{ext};base64,{base64_str}"
resp = await loop.run_in_executor(None, lambda: _do_request(data_uri))
else:
logger.error("Base64 download failed during fallback.")
return []
else:
logger.error(f"API BadRequestError: {e}")
return []
content = resp.choices[0].message.content or ""
raw = ReadImageKit._extract_json(content)
return json.loads(raw)
except Exception as e:
logger.error(f"API Unexpected Error: {e}")
return []
content = resp.choices[0].message.content or ""
raw = ReadImageKit._extract_json(content)
try:
# 反序列化模型返回的 JSON
rows = json.loads(raw)
if not isinstance(rows, list):
return []
norm = []
for r in rows:
if not isinstance(r, dict):
continue
# 兼容不同来源字段命名,统一为目标键
start = r.get("start")
end = r.get("end")
price = r.get("price_kwh")
elec = r.get("electric_fee_kwh")
serv = r.get("service_fee_kwh")
# station_name = station_name
# 将字符串数字安全转换为 float;缺失则为 None
norm.append({
"station_name": station_name,
"start": start,
"end": end,
"price_kwh": float(price) if isinstance(price, (int, float, str)) and str(price) else None,
"electric_fee_kwh": float(elec) if isinstance(elec, (int, float, str)) and str(elec) else None,
"service_fee_kwh": float(serv) if isinstance(serv, (int, float, str)) and str(serv) else None,
})
for i in range(1, len(norm)):
curr = norm[i]
prev = norm[i - 1]
for k in ("price_kwh", "electric_fee_kwh", "service_fee_kwh"):
if curr.get(k) is None and prev.get(k) is not None:
curr[k] = prev[k]
return norm
except Exception as e:
logger.error(f"Error parsing JSON: {e}")
logger.error(f"Raw content: {raw}")
logger.error(f"解析价格失败 ({station_name}): {e}")
return []
@staticmethod
def _to_minutes(t: str) -> int:
async def parse_address(station_name: str, image_url_or_path: str, device_info: dict = None) -> dict:
"""
将 "HH:MM" 转为分钟数(0-1440)
说明:
- 特殊处理 "24:00" -> 1440,方便区间闭合处理
- 非法格式返回 0
"""
if not t:
return 0
try:
h, m = t.split(":")
h = int(h)
m = int(m)
if h == 24 and m == 0:
return 24 * 60
return h * 60 + m
except Exception:
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_hourly(rows: list) -> list:
"""
将时段列表按小时边界拆分
参数:
rows: parse_price_schedule 返回的时段列表
返回:
每小时一条数据的列表,区间为闭开 [start, end) 的连续小时段
说明:
- 例如 05:00-08:00 -> 05:00-06:00, 06:00-07:00, 07:00-08:00
- 保留每小时的价格、电费、服务费不变
"""
hourly = []
for r in rows:
# 起止时间转分钟
s = ReadImageKit._to_minutes(r.get("start"))
e = ReadImageKit._to_minutes(r.get("end"))
if e <= s:
continue
cur = s
while cur < e:
# 下一小时边界;不超过区间终点
nxt = min(e, ((cur // 60) + 1) * 60)
hourly.append({
"start": ReadImageKit._fmt(cur),
"end": ReadImageKit._fmt(nxt),
"price_kwh": r.get("price_kwh"),
"electric_fee_kwh": r.get("electric_fee_kwh"),
"service_fee_kwh": r.get("service_fee_kwh"),
})
cur = nxt
return hourly
@staticmethod
async def _fetch_md5(url: str) -> str:
"""
下载图片并计算 MD5(用于内容去重)
返回:
32位十六进制 MD5 字符串;失败返回空字符串
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status != 200:
return ""
content = await resp.read()
return hashlib.md5(content).hexdigest()
except Exception:
return ""
@staticmethod
async def parse_address(station_name: str, image_url: str, device_info: dict = None) -> dict:
"""
解析图片中的充电站地址信息,同时识别“全部时段”按钮坐标
参数:
station_name:场站名称
image_url: 图片地址
device_info: 设备信息(可选)
返回:
字典,包含 address 字段以及 uia_center_x/uia_center_y (如果找到按钮)
解析地址信息,支持 URL 或 本地路径
"""
if device_info is None:
logger.warning("未提供动态设备信息,使用通用回退配置。")
device_info = ReadImageKit._FALLBACK_DEVICE_INFO
# 启动寻找“全部时段”按钮的任务,为后续可能的点击做准备
button_task = asyncio.create_task(ReadImageKit.find_all_time_button_coordinate(image_url, device_info))
# 处理图片输入 (URL or Local Path)
if os.path.exists(image_url_or_path):
with open(image_url_or_path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
img_input = {"url": f"data:image/jpeg;base64,{encoded}"}
else:
img_input = {"url": image_url_or_path}
# 寻找按钮坐标任务
button_task = asyncio.create_task(ReadImageKit.find_all_time_button_coordinate(image_url_or_path, device_info))
prompt = (
"仅输出JSON对象(不含任何说明文字)。"
@@ -1136,78 +1000,56 @@ class ReadImageKit:
"严格返回纯JSON格式。"
)
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None,
lambda: ReadImageKit._client.chat.completions.create(
result = {}
try:
resp = await ReadImageKit._client.chat.completions.create(
model=VL_MODEL_NAME,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": image_url},
},
{"type": "image_url", "image_url": img_input},
{"type": "text", "text": json.dumps(device_info, ensure_ascii=False)},
{"type": "text", "text": prompt},
],
},
],
)
)
content = resp.choices[0].message.content or ""
raw = ReadImageKit._extract_json(content)
result = {}
try:
data = json.loads(raw)
if isinstance(data, dict):
result = data
content = resp.choices[0].message.content or ""
raw = ReadImageKit._extract_json(content)
result = json.loads(raw)
except Exception as e:
logger.error(f"Error parsing address JSON: {e}")
logger.error(f"解析地址失败 ({station_name}): {e}")
# 等待并合并按钮坐标结果
# 合并按钮坐标
try:
button_result = await button_task
if button_result:
result.update(button_result)
except Exception as e:
logger.error(f"Error in button coordinate task: {e}")
logger.error(f"按钮坐标获取失败: {e}")
return result
@staticmethod
async def parse_price_schedule_multi(station_name: str, image_urls: list, device_info: dict = None) -> list:
async def parse_price_schedule_multi(station_name: str, image_urls_or_paths: list, device_info: dict = None) -> list:
"""
多图解析(按图片内容 MD5 去重)并合并时段结果
参数:
image_urls: 多张价格表图片的 URL 列表
device_info: 设备信息(可选)
逻辑:
1. 逐张下载并计算 MD5,去重得到唯一图片集合
2. 对每张唯一图片解析价格时段
3. 将所有图片的时段行合并为一个列表返回
返回:
合并后的时段列表(未按小时拆分)
多图解析并去重合并
"""
if not image_urls:
if not image_urls_or_paths:
return []
# MD5 -> URL 的映射,用于去重
md5_to_url = {}
for u in image_urls:
m = await ReadImageKit._fetch_md5(u)
if m and m not in md5_to_url:
md5_to_url[m] = u
unique_urls = list(md5_to_url.values())
# 合并时段结果
# MD5 去重
md5_to_img = {}
for img in image_urls_or_paths:
m = await ReadImageKit.get_image_md5(img)
if m and m not in md5_to_img:
md5_to_img[m] = img
unique_imgs = list(md5_to_img.values())
combined = []
for u in unique_urls:
rows = await ReadImageKit.parse_price_schedule(station_name, u, device_info=device_info)
for img in unique_imgs:
rows = await ReadImageKit.parse_price_schedule(station_name, img, device_info=device_info)
if rows:
combined.extend(rows)
return combined