'commit'
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user