'commit'
This commit is contained in:
@@ -76,124 +76,107 @@ async def get_station_list(d, service, max_scrolls=MAX_SCROLLS):
|
||||
|
||||
logger.info(f"列表页截图已完成: {screenshot_path}")
|
||||
|
||||
# 2. 执行图形学分析,生成 _flag.jpg, _vl.jpg 和 .json
|
||||
logger.info("正在执行图形学切片分析...")
|
||||
json_data = Kit.crop_cards_from_image(screenshot_path)
|
||||
|
||||
# 3. 调用混合模式识别 (图形学切片 + 本地 OCR)
|
||||
# 2. 调用混合模式识别 (内部包含图形学切片 + VL 模型)
|
||||
logger.info("正在执行混合模式识别 (切片 + VL)...")
|
||||
stations = await service.process_station_list_hybrid(screenshot_path, device_info=device_info)
|
||||
logger.info(f"本页识别到 {len(stations)} 个场站")
|
||||
logger.info(f"本页识别到 {len(stations)} 个有效场站卡片")
|
||||
|
||||
# 4. 匹配几何卡片与识别结果 (混合模式下已经包含在 stations 中,但为了兼容旧逻辑进行填充)
|
||||
if json_data and json_data.get("cards") and stations:
|
||||
for card in json_data["cards"]:
|
||||
card_rect = card["rect"] # [x1, y1, x2, y2]
|
||||
for st in stations:
|
||||
st_bounds = st.get("bounds") # [x1, y1, x2, y2] (0-1000)
|
||||
if not st_bounds: continue
|
||||
|
||||
# 转换 VL 坐标到像素坐标
|
||||
st_y1_px = st_bounds[1] * h / 1000
|
||||
st_y2_px = st_bounds[3] * h / 1000
|
||||
|
||||
# 计算 y 轴重叠
|
||||
overlap = min(card_rect[3], st_y2_px) - max(card_rect[1], st_y1_px)
|
||||
if overlap > 50: # 有显著重叠
|
||||
card["station_name"] = st.get("station_name")
|
||||
logger.info(f"匹配成功: 几何卡片 {card_rect} -> 场站 {card['station_name']}")
|
||||
break
|
||||
# 3. 遍历处理本页所有场站
|
||||
for card_idx, station in enumerate(stations):
|
||||
station_name = station.get("station_name")
|
||||
if not station_name:
|
||||
continue
|
||||
|
||||
# 检查 Redis 去重
|
||||
if await is_already_crawled(redis_kit, station_name):
|
||||
logger.info(f"场站 {station_name} 匹配到已处理记录,跳过。")
|
||||
continue
|
||||
|
||||
# 5. 遍历处理本页所有场站
|
||||
if json_data and json_data.get("cards"):
|
||||
for card_idx, card in enumerate(json_data["cards"]):
|
||||
station_name = card.get("station_name")
|
||||
if not station_name:
|
||||
logger.warning(f"第 {card_idx + 1} 个几何卡片未匹配到场站名称,跳过。")
|
||||
continue
|
||||
# 使用识别结果中自带的点击坐标
|
||||
click_x = station.get("uia_center_x")
|
||||
click_y = station.get("uia_center_y")
|
||||
|
||||
if click_x is None or click_y is None:
|
||||
logger.warning(f"场站 {station_name} 缺失点击坐标,跳过。")
|
||||
continue
|
||||
|
||||
logger.info(f"准备处理第 {card_idx + 1} 个场站: {station_name}, 点击坐标: ({click_x}, {click_y})")
|
||||
d.click(click_x, click_y)
|
||||
|
||||
# 等待二级页面加载
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 截取二级页面图
|
||||
detail_uuid = f"detail_{station_name}_{image_uuid}"
|
||||
detail_path = take_screenshot(d, detail_uuid, save_dir=TEMP_IMAGE_DIR)
|
||||
|
||||
# 后台分析详情页基础数据
|
||||
logger.info(f"已启动后台分析详情页: {station_name}")
|
||||
|
||||
async def detail_task_wrapper(p, name):
|
||||
try:
|
||||
await service.process_station_detail(p, station_name=name)
|
||||
if os.path.exists(p): os.remove(p)
|
||||
except Exception as ex:
|
||||
logger.error(f"详情页分析异常 ({name}): {ex}")
|
||||
|
||||
task_detail = asyncio.create_task(detail_task_wrapper(detail_path, station_name))
|
||||
background_tasks.append(task_detail)
|
||||
|
||||
# 4. 寻找 timePrice.jpg 图标并进入三级页面
|
||||
time_price_template = os.path.join(os.path.dirname(__file__), "BiaoShi", "timePrice.jpg")
|
||||
coords = Kit.find_template_coords(detail_path, time_price_template)
|
||||
|
||||
if coords:
|
||||
cx, cy, conf = coords
|
||||
logger.info(f"找到分时价格图标,进入三级页面...")
|
||||
d.click(cx, cy)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 检查 Redis 去重 (使用新的模糊匹配逻辑)
|
||||
if await is_already_crawled(redis_kit, station_name):
|
||||
logger.info(f"场站 {station_name} 匹配到已处理记录,跳过。")
|
||||
continue
|
||||
|
||||
click_x, click_y = card["click_point"]
|
||||
logger.info(f"准备处理第 {card_idx + 1} 个场站: {station_name}, 点击坐标: ({click_x}, {click_y})")
|
||||
d.click(click_x, click_y)
|
||||
# 抓取三级页面图片
|
||||
price_screenshots = []
|
||||
price_detail_uuid = f"price_detail_{station_name}_{image_uuid}"
|
||||
price_path = take_screenshot(d, price_detail_uuid, save_dir=TEMP_IMAGE_DIR)
|
||||
price_screenshots.append(price_path)
|
||||
|
||||
# 等待二级页面加载
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 截取二级页面图
|
||||
detail_uuid = f"detail_{station_name}_{image_uuid}"
|
||||
detail_path = take_screenshot(d, detail_uuid, save_dir=TEMP_IMAGE_DIR)
|
||||
|
||||
# 【异步优化】后台解析详情页基础数据,不阻塞 UI 流程
|
||||
logger.info(f"已启动后台分析详情页: {station_name}")
|
||||
|
||||
async def detail_task_wrapper(p, name):
|
||||
try:
|
||||
await service.process_station_detail(p, station_name=name)
|
||||
if os.path.exists(p): os.remove(p)
|
||||
except Exception as ex:
|
||||
logger.error(f"详情页分析异常 ({name}): {ex}")
|
||||
# 滑动处理
|
||||
last_price_md5 = Kit.get_file_md5(price_path)
|
||||
for scroll_idx in range(2):
|
||||
d.swipe(w // 2, int(h * 0.7), w // 2, int(h * 0.3), duration=0.4)
|
||||
await asyncio.sleep(1)
|
||||
scroll_path = take_screenshot(d, f"price_scroll_{scroll_idx}_{station_name}", save_dir=TEMP_IMAGE_DIR)
|
||||
curr_price_md5 = Kit.get_file_md5(scroll_path)
|
||||
if curr_price_md5 == last_price_md5: break
|
||||
price_screenshots.append(scroll_path)
|
||||
last_price_md5 = curr_price_md5
|
||||
|
||||
task_detail = asyncio.create_task(detail_task_wrapper(detail_path, station_name))
|
||||
background_tasks.append(task_detail)
|
||||
|
||||
# 6. 寻找 timePrice.jpg 图标并进入三级页面 (分时价格页)
|
||||
time_price_template = os.path.join(os.path.dirname(__file__), "BiaoShi", "timePrice.jpg")
|
||||
coords = Kit.find_template_coords(detail_path, time_price_template)
|
||||
# 后台处理价格图片
|
||||
logger.info(f"已启动后台分析价格页: {station_name},共 {len(price_screenshots)} 张图")
|
||||
task_price = asyncio.create_task(analyze_prices_background(service, station_name, price_screenshots))
|
||||
background_tasks.append(task_price)
|
||||
|
||||
if coords:
|
||||
cx, cy, conf = coords
|
||||
logger.info(f"找到分时价格图标,进入三级页面...")
|
||||
d.click(cx, cy)
|
||||
await asyncio.sleep(2) # 等待加载
|
||||
|
||||
# 抓取三级页面图片(包括可能的滑动)
|
||||
price_screenshots = []
|
||||
price_detail_uuid = f"price_detail_{station_name}_{image_uuid}"
|
||||
price_path = take_screenshot(d, price_detail_uuid, save_dir=TEMP_IMAGE_DIR)
|
||||
price_screenshots.append(price_path)
|
||||
|
||||
# 如果需要滑动,先完成所有截图动作
|
||||
last_md5 = Kit.get_file_md5(price_path)
|
||||
for scroll_idx in range(2): # 减少滑动次数以加快速度,通常1-2次足够
|
||||
d.swipe(w // 2, int(h * 0.7), w // 2, int(h * 0.3), duration=0.4)
|
||||
await asyncio.sleep(1)
|
||||
scroll_path = take_screenshot(d, f"price_scroll_{scroll_idx}_{station_name}", save_dir=TEMP_IMAGE_DIR)
|
||||
curr_md5 = Kit.get_file_md5(scroll_path)
|
||||
if curr_md5 == last_md5: break
|
||||
price_screenshots.append(scroll_path)
|
||||
last_md5 = curr_md5
|
||||
|
||||
# 【异步优化】后台处理所有价格图片,不阻塞 UI 返回
|
||||
logger.info(f"已启动后台分析价格页: {station_name},共 {len(price_screenshots)} 张图")
|
||||
task_price = asyncio.create_task(analyze_prices_background(service, station_name, price_screenshots))
|
||||
background_tasks.append(task_price)
|
||||
|
||||
# 立即从三级页面返回
|
||||
d.press("back")
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
logger.warning(f"场站 {station_name} 未找到分时价格入口。")
|
||||
|
||||
# 从二级页面返回列表页
|
||||
# 立即返回
|
||||
d.press("back")
|
||||
await asyncio.sleep(0.8)
|
||||
|
||||
# 记录 Redis (仅按名称去重,不再使用前缀索引)
|
||||
full_name_key = f"crawled:aite:{Kit.clean_station_name(station_name)}"
|
||||
await redis_kit.set_data(full_name_key, "1", expire=REDIS_STATION_EXPIRE)
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
logger.warning(f"场站 {station_name} 未找到分时价格入口。")
|
||||
|
||||
# 在每一页结束时,清理已完成的任务,防止内存堆积
|
||||
# 从二级页面返回列表页
|
||||
d.press("back")
|
||||
await asyncio.sleep(0.8)
|
||||
|
||||
# 记录 Redis
|
||||
full_name_key = f"crawled:aite:{Kit.clean_station_name(station_name)}"
|
||||
await redis_kit.set_data(full_name_key, "1", expire=REDIS_STATION_EXPIRE)
|
||||
|
||||
# 在每一页结束时,清理已完成的任务
|
||||
done_tasks = [t for t in background_tasks if t.done()]
|
||||
for t in done_tasks:
|
||||
if t.exception():
|
||||
logger.error(f"后台任务执行异常: {t.exception()}")
|
||||
background_tasks.remove(t)
|
||||
|
||||
# 7. 列表页翻页滑动
|
||||
# 5. 列表页翻页滑动
|
||||
logger.info("执行翻页滑动...")
|
||||
start_x, start_y = w // 2, int(h * 0.8)
|
||||
end_x, end_y = w // 2, int(h * (0.8 - SCROLL_DISTANCE_RATIO))
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
### 2.1 第一级:场站列表页
|
||||
- **操作**: 自动滚动列表,每屏截取一张大图。
|
||||
- **识别方法**:
|
||||
- **图形学切片**: 使用 OpenCV 识别图片中的白色卡片区域(几何卡片),确定点击位置。
|
||||
- **VL 模型分析**: 使用 `Qwen-VL` 模型识别本页所有场站的名称、基础价格和归一化坐标。
|
||||
- **坐标匹配**: 将几何卡片的像素坐标与 VL 模型的归一化坐标进行交叉比对,确定每个卡片对应的场站名称。
|
||||
- **关键点**: 通过几何卡片确保点击的准确性,通过 VL 模型确保数据的语义准确性。
|
||||
- **图形学切片**: 使用 OpenCV 识别图片中的白色卡片区域(几何卡片),将其裁剪为独立的小图。
|
||||
- **VL 并行识别**: 对裁剪出的每个场站小图,并行调用 `Qwen-VL` 模型提取名称、价格、枪数等信息。
|
||||
- **坐标映射**: 将 VL 识别结果直接映射回原始几何卡片的点击坐标。
|
||||
- **关键点**: 通过几何切片降低 VL 模型的识别负担,通过并行调用提高整体处理速度。
|
||||
|
||||
### 2.2 第二级:场站详情页
|
||||
- **进入**: 点击列表页卡片。
|
||||
|
||||
@@ -8,6 +8,7 @@ import aiohttp
|
||||
import logging
|
||||
import base64
|
||||
import cv2
|
||||
import uuid
|
||||
from openai import AsyncOpenAI, BadRequestError
|
||||
from Config.Config import (
|
||||
ALY_LLM_API_KEY, VL_MODEL_NAME, VL_MODEL_NAME_AD
|
||||
@@ -16,7 +17,6 @@ from Apps.AiTeJiYiChong.Config.Setting import (
|
||||
SAFE_EXCLUDE_RATIO, FALLBACK_WIDTH, FALLBACK_HEIGHT,
|
||||
BOTTOM_SAFE_EXCLUDE_RATIO
|
||||
)
|
||||
from Util.PaddleOCRKit import get_ocr_kit
|
||||
from Apps.AiTeJiYiChong import Kit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -290,51 +290,107 @@ class ReadImageKit:
|
||||
logger.error(f"Error calling VL model for detail: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_stations_hybrid(cls, image_path: str, device_info=None):
|
||||
@staticmethod
|
||||
async def recognize_card_with_vl(image_path: str):
|
||||
"""
|
||||
混合识别模式:图形学切片 + 本地 PaddleOCR 识别
|
||||
对卡片切片进行 VL 识别
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
logger.error(f"Image not found: {image_path}")
|
||||
return []
|
||||
return None
|
||||
|
||||
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 = (
|
||||
"请识别图片中的充电站卡片信息,并以 JSON 格式输出:\n"
|
||||
"{\n"
|
||||
" \"station_name\": \"场站名称\",\n"
|
||||
" \"price\": \"价格(数字)\",\n"
|
||||
" \"piles\": \"空闲/总数(如 4/4)\",\n"
|
||||
" \"distance\": \"距离(如 2.5km)\",\n"
|
||||
" \"parking\": \"停车描述\"\n"
|
||||
"}\n"
|
||||
"只输出 JSON,不要有其他文字。"
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await ReadImageKit._client.chat.completions.create(
|
||||
model=VL_MODEL_NAME,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": img_input}
|
||||
]
|
||||
}
|
||||
],
|
||||
max_tokens=300,
|
||||
temperature=0.01
|
||||
)
|
||||
content = resp.choices[0].message.content.strip()
|
||||
raw = ReadImageKit._extract_json(content)
|
||||
return json.loads(raw)
|
||||
except Exception as e:
|
||||
logger.error(f"VL recognize_card_with_vl error: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_stations_hybrid(image_path, device_info=None):
|
||||
"""
|
||||
混合识别模式:图形学切片 + VL 模型识别
|
||||
"""
|
||||
if device_info is None:
|
||||
device_info = ReadImageKit._FALLBACK_DEVICE_INFO
|
||||
|
||||
# 1. 使用 Kit 中的图形学算法识别卡片区域
|
||||
# Kit.crop_cards_from_image 会生成 .json, _flag.jpg, _vl.jpg
|
||||
# 我们主要需要它返回的 json_data
|
||||
json_data = Kit.crop_cards_from_image(image_path, save_debug=True)
|
||||
if not json_data or not json_data.get("cards"):
|
||||
logger.warning("No cards detected by graphical slicing.")
|
||||
return []
|
||||
|
||||
# 2. 对每个卡片区域进行 VL 识别
|
||||
tasks = []
|
||||
card_metas = []
|
||||
|
||||
# 为了使用 VL,我们需要将每个卡片切出来保存到临时文件
|
||||
temp_dir = os.path.join(os.path.dirname(image_path), "Patches")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
img = Image.open(image_path).convert("RGB")
|
||||
ocr_kit = get_ocr_kit()
|
||||
|
||||
final_stations = []
|
||||
|
||||
# 2. 对每个卡片区域进行 OCR 识别
|
||||
# 注意:PaddleOCR 识别过程较快,且通常不涉及网络请求,可以根据需要选择并行或串行
|
||||
# 这里使用串行以保证日志输出整齐,如果追求极致性能可改用 asyncio.to_thread 并行
|
||||
for card in json_data["cards"]:
|
||||
rect = card["rect"] # [x1, y1, x2, y2]
|
||||
# 裁剪卡片
|
||||
patch_path = os.path.join(temp_dir, f"patch_{card['id']}_{uuid.uuid4().hex[:8]}.jpg")
|
||||
|
||||
# 裁剪并保存
|
||||
patch = img.crop((rect[0], rect[1], rect[2], rect[3]))
|
||||
patch.save(patch_path)
|
||||
|
||||
# 转换为 ndarray 供 PaddleOCR 使用
|
||||
patch_cv = cv2.cvtColor(np.array(patch), cv2.COLOR_RGB2BGR)
|
||||
card_metas.append({
|
||||
"meta": card,
|
||||
"patch_path": patch_path
|
||||
})
|
||||
tasks.append(ReadImageKit.recognize_card_with_vl(patch_path))
|
||||
|
||||
# 并行执行 VL 识别
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
final_stations = []
|
||||
for i, res in enumerate(results):
|
||||
meta = card_metas[i]["meta"]
|
||||
patch_path = card_metas[i]["patch_path"]
|
||||
|
||||
# OCR 识别
|
||||
logger.info(f"正在识别卡片 {card['id']}: {rect}")
|
||||
res = ocr_kit.recognize(patch_cv)
|
||||
|
||||
if res and res.get("station_name"):
|
||||
if res and isinstance(res, dict) and res.get("station_name"):
|
||||
# 注入点击坐标和原始区域信息
|
||||
res["uia_center_x"] = card["click_point"][0]
|
||||
res["uia_center_y"] = card["click_point"][1]
|
||||
res["rect"] = rect
|
||||
res["uia_center_x"] = meta["click_point"][0]
|
||||
res["uia_center_y"] = meta["click_point"][1]
|
||||
res["rect"] = meta["rect"]
|
||||
|
||||
# 转换 bounds 到 0-1000 空间(保持与 VL 模式兼容)
|
||||
# 转换 bounds 到 0-1000 空间
|
||||
w, h = img.size
|
||||
rect = meta["rect"]
|
||||
res["bounds"] = [
|
||||
int(rect[0] * 1000 / w),
|
||||
int(rect[1] * 1000 / h),
|
||||
@@ -343,9 +399,16 @@ class ReadImageKit:
|
||||
]
|
||||
|
||||
final_stations.append(res)
|
||||
logger.info(f"卡片 {card['id']} 识别成功: {res['station_name']}")
|
||||
logger.info(f"卡片 {meta['id']} 识别成功: {res['station_name']}")
|
||||
else:
|
||||
logger.warning(f"卡片 {card['id']} 识别失败或无名称")
|
||||
logger.warning(f"卡片 {meta['id']} 识别失败或无名称")
|
||||
|
||||
# 清理临时切片文件
|
||||
try:
|
||||
if os.path.exists(patch_path):
|
||||
os.remove(patch_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
return final_stations
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ class AiTeJiYiChongService:
|
||||
|
||||
async def process_station_list_hybrid(self, image_path, device_info=None) -> list:
|
||||
"""
|
||||
基于混合模式处理场站列表 (图形学切片 + 本地 OCR)
|
||||
基于混合模式处理场站列表 (图形学切片 + VL 模型)
|
||||
"""
|
||||
station_list = await ReadImageKit.get_stations_hybrid(image_path, device_info=device_info)
|
||||
if not station_list:
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user