diff --git a/Apps/AiTeJiYiChong/Config/Setting.py b/Apps/AiTeJiYiChong/Config/Setting.py index e1efa01..97ab525 100644 --- a/Apps/AiTeJiYiChong/Config/Setting.py +++ b/Apps/AiTeJiYiChong/Config/Setting.py @@ -1,7 +1,7 @@ # 采集配置 SCROLL_DISTANCE_RATIO = 0.3 -MAX_STATIONS_COUNT =20 +MAX_STATIONS_COUNT = 10 FIRST_RUN_ONLY_ONE_STATION = False REDIS_STATION_EXPIRE = 120 DATA_RETENTION_DAYS = 365 @@ -17,6 +17,8 @@ WAIT_AFTER_SCROLL = 2.0 # 坐标计算与安全防护 SAFE_EXCLUDE_RATIO = 0.20 BOTTOM_SAFE_EXCLUDE_RATIO = 0.12 +STATION_BOX_WIDTH_RATIO = 0.95 +STATION_BOX_HEIGHT_RATIO = 0.18 FALLBACK_WIDTH = 1080 FALLBACK_HEIGHT = 2400 diff --git a/Apps/AiTeJiYiChong/Config/__pycache__/Setting.cpython-310.pyc b/Apps/AiTeJiYiChong/Config/__pycache__/Setting.cpython-310.pyc index e60a7cd..b0cd5cc 100644 Binary files a/Apps/AiTeJiYiChong/Config/__pycache__/Setting.cpython-310.pyc and b/Apps/AiTeJiYiChong/Config/__pycache__/Setting.cpython-310.pyc differ diff --git a/Apps/AiTeJiYiChong/Crawler.py b/Apps/AiTeJiYiChong/Crawler.py index 1f9d59e..ec12984 100644 --- a/Apps/AiTeJiYiChong/Crawler.py +++ b/Apps/AiTeJiYiChong/Crawler.py @@ -17,6 +17,7 @@ import uiautomator2 as u2 from Apps.AiTeJiYiChong import Kit from Apps.AiTeJiYiChong.Kit import take_screenshot, setup_logger from Apps.AiTeJiYiChong.ReadImageKit import ReadImageKit +from Apps.AiTeJiYiChong.FirstPageKit import run_ocr_rect from Util.RedisKit import RedisKit from Apps.AiTeJiYiChong.Service import AiTeJiYiChongService from Config.Config import TEMP_IMAGE_DIR @@ -106,224 +107,226 @@ async def get_station_list(d, service, max_stations_count=MAX_STATIONS_COUNT): logger.info(f"列表页截图已完成: {screenshot_path}") - # 2. 执行本地图形学分析,生成 _vl.jpg 和 .json - logger.info("正在执行图形学切片分析...") - json_metadata = Kit.crop_cards_from_image(screenshot_path, save_debug=True) - - vl_img_path = screenshot_path.replace(".jpg", "_vl.jpg") + # 2. 使用 OCR+LLM 识别当前页所有场站卡片 + logger.info("正在使用 OCR+LLM 识别场站列表...") + try: + stations_page = await run_ocr_rect(screenshot_path) + except Exception as e: + logger.error(f"OCR+LLM 识别列表页异常: {e}") + stations_page = None - if not json_metadata or not json_metadata.get("cards"): - logger.warning("未识别到任何卡片,跳过当前页") + if not stations_page: + logger.warning("OCR+LLM 未识别到任何场站,跳过当前页") continue - # 3. 后台异步上传 _vl.jpg 供以后参考 (如果需要,目前仅本地处理) - - # 4. 调用 VL 模型识别场站信息 (整页识别) - logger.info("正在调用 VL 模型识别场站信息 (整页识别)...") - # 优先使用带绿框的图片进行识别 - img_to_process = vl_img_path if os.path.exists(vl_img_path) else screenshot_path - stations = await service.process_station_list_vl(img_to_process, json_metadata, device_info=device_info, max_count=max_stations_count - total_new_processed_count) - logger.info(f"本页识别到 {len(stations)} 个有效场站") + ad_top_y = int(h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)) - ad_top_y_norm = 1.0 - BOTTOM_SAFE_EXCLUDE_RATIO - - # 5. 遍历处理本页所有场站 + # 3. 遍历处理本页所有场站 new_stations_processed_in_page = 0 - if json_metadata.get("cards") and stations: - for card_idx, card in enumerate(json_metadata["cards"]): - # 检查是否已达到最大采集数量(按新采集的场站数量限制) + if stations_page: + stations_page_sorted = [] + for st in stations_page: + rect = st.get("rect") or [] + if not isinstance(rect, (list, tuple)) or len(rect) < 4: + continue + stations_page_sorted.append(st) + stations_page_sorted.sort(key=lambda s: (s.get("rect") or [0, 0, 0, 0])[1]) + + for st in stations_page_sorted: if total_new_processed_count >= max_stations_count: break - # 检查索引是否越界 (VL 模型可能返回的数组长度不一致) - if card_idx < len(stations) and stations[card_idx]: - station = stations[card_idx] - station_name = station.get("station_name") - if not station_name: continue - - # 只要是有效的场站,就计入已遇到数量 - total_encountered_count += 1 + station_name = st.get("station_name") or st.get("name") + rect = st.get("rect") or [] + click_point = st.get("click_point") or st.get("click") or [] - # 检查 Redis 去重 - if await is_already_crawled(redis_kit, station_name): - logger.info(f"场站 {station_name} 匹配到已处理记录,跳过。({total_encountered_count}/{max_stations_count})") - continue + if not station_name or not isinstance(rect, (list, tuple)) or len(rect) < 4: + continue + if not isinstance(click_point, (list, tuple)) or len(click_point) < 2: + continue - # 检查是否被遮挡或太靠近底部 - card_bottom = card["bounds_norm"]["bottom"] - if card_bottom > ad_top_y_norm: - logger.warning(f"场站 '{station_name}' 被遮挡或太靠近底部 (Bottom {card_bottom:.2f} > {ad_top_y_norm}),留待下次滚动处理。") - # 还没处理,不能算作已遇到 - total_encountered_count -= 1 - continue + x1, y1, x2, y2 = rect[0], rect[1], rect[2], rect[3] + if not (0 <= x1 < x2 <= w and 0 <= y1 < y2 <= h): + logger.warning(f"场站 '{station_name}' 的矩形越界,跳过: rect={rect}, image=({w},{h})") + continue - # 正常处理新场站 - click_x, click_y = card["click_point"] - current_idx = total_new_processed_count + 1 - remaining = max_stations_count - current_idx - logger.info(f"--- [进度: {current_idx}/{max_stations_count}, 剩余: {remaining}] 发现新场站 '{station_name}',开始处理... ---") - new_stations_processed_in_page += 1 - total_new_processed_count += 1 - d.click(click_x, click_y) - - # 等待二级页面加载 - await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD) - - should_back_to_list = True + click_x, click_y = int(click_point[0]), int(click_point[1]) + if not (x1 <= click_x <= x2 and y1 <= click_y <= y2): + logger.warning(f"场站 '{station_name}' 点击点不在矩形内部,跳过: click={click_point}, rect={rect}") + continue - # 截取二级页面图 - 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}") + total_encountered_count += 1 - task_detail = asyncio.create_task(detail_task_wrapper(detail_path, station_name)) - background_tasks.append(task_detail) + if await is_already_crawled(redis_kit, station_name): + logger.info(f"场站 {station_name} 匹配到已处理记录,跳过。({total_encountered_count}/{max_stations_count})") + continue - # 4. 寻找分时价格入口并进入三级页面 - # 【优化】梯级识别策略:优先模板匹配 (timePrice.jpg),失败则降级为 VL 识别 - template_time_price = os.path.join(os.path.dirname(__file__), "Template", "timePrice.jpg") - - # 记录点击前的页面特征,用于验证是否成功进入三级页面 - before_click_md5 = Kit.get_image_content_md5(detail_path) - - entered_price_page = False - click_pos = None - + if y2 > ad_top_y: + logger.info(f"场站 '{station_name}' 底部靠近屏幕底部 (y2={y2}, ad_top_y={ad_top_y}),留待下次滚动处理。") + total_encountered_count -= 1 + continue + + current_idx = total_new_processed_count + 1 + remaining = max_stations_count - current_idx + logger.info(f"--- [进度: {current_idx}/{max_stations_count}, 剩余: {remaining}] 发现新场站 '{station_name}',开始处理... ---") + new_stations_processed_in_page += 1 + total_new_processed_count += 1 + d.click(click_x, click_y) + + await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD) + + should_back_to_list = True + + 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: - # 1. 尝试模板匹配 - if os.path.exists(template_time_price): - match_res = d.image.match(template_time_price) - if match_res: - # 兼容 uiautomator2 不同版本的返回结果 (Match对象或dict) - if hasattr(match_res, 'point') and match_res.point: - click_pos = match_res.point - elif isinstance(match_res, dict): - if 'point' in match_res and match_res['point']: - click_pos = match_res['point'] - elif 'x' in match_res and 'y' in match_res: - click_pos = (match_res['x'], match_res['y']) - elif isinstance(match_res, (list, tuple)) and len(match_res) >= 2: - click_pos = (match_res[0], match_res[1]) - - if click_pos: - logger.info(f"通过 timePrice.jpg 成功找到!坐标: {click_pos}") - - # 如果模板匹配未成功解析坐标,或者匹配直接失败,则降级到 VL - if not click_pos: - logger.info("模板匹配未找到或解析失败,降级使用 VL 识别...") - res = await ReadImageKit.find_time_price_button_coordinate(detail_path, device_info=device_info) - if res.get("uia_center_x"): - click_pos = (res.get("uia_center_x"), res.get("uia_center_y")) - logger.info(f"VL 识别成功找到 '分时价格' 按钮: {click_pos}") + 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}") - if click_pos: - click_x, click_y = int(click_pos[0]), int(click_pos[1]) - bottom_safe_y = int(h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)) - if click_y >= bottom_safe_y: - logger.warning(f"'分时价格'按钮识别坐标 ({click_x}, {click_y}) 落在底部安全区域 (>= {bottom_safe_y}),为避免误触底部扫码/导航按钮,本次不点击。") - else: - d.click(click_x, click_y) - await asyncio.sleep(0.5) + task_detail = asyncio.create_task(detail_task_wrapper(detail_path, station_name)) + background_tasks.append(task_detail) + + # 4. 寻找分时价格入口并进入三级页面 + # 【优化】梯级识别策略:优先模板匹配 (timePrice.jpg),失败则降级为 VL 识别 + template_time_price = os.path.join(os.path.dirname(__file__), "Template", "timePrice.jpg") + + # 记录点击前的页面特征,用于验证是否成功进入三级页面 + before_click_md5 = Kit.get_image_content_md5(detail_path) + + entered_price_page = False + click_pos = None + + try: + # 1. 尝试模板匹配 + if os.path.exists(template_time_price): + match_res = d.image.match(template_time_price) + if match_res: + # 兼容 uiautomator2 不同版本的返回结果 (Match对象或dict) + if hasattr(match_res, 'point') and match_res.point: + click_pos = match_res.point + elif isinstance(match_res, dict): + if 'point' in match_res and match_res['point']: + click_pos = match_res['point'] + elif 'x' in match_res and 'y' in match_res: + click_pos = (match_res['x'], match_res['y']) + elif isinstance(match_res, (list, tuple)) and len(match_res) >= 2: + click_pos = (match_res[0], match_res[1]) - # 检查页面是否真的变化了 - after_click_path = take_screenshot(d, f"check_{detail_uuid}", save_dir=TEMP_IMAGE_DIR) - after_click_md5 = Kit.get_image_content_md5(after_click_path) - - if before_click_md5 != after_click_md5: - entered_price_page = True - price_screenshots = [] - - logger.info("正在向上滚动价格列表到顶部 (快速多次滚动以尽快看到 00:00)...") - max_scroll_up_to_top = 10 - for i in range(max_scroll_up_to_top): - before_scroll_path = take_screenshot(d, f"aite_price_up_{i}", save_dir=TEMP_IMAGE_DIR) - before_scroll_md5 = Kit.get_image_content_md5(before_scroll_path) - - d.swipe_ext("down", scale=0.85) - await asyncio.sleep(0.3) - - after_scroll_path = take_screenshot(d, f"aite_price_up_after_{i}", save_dir=TEMP_IMAGE_DIR) - after_scroll_md5 = Kit.get_image_content_md5(after_scroll_path) - - if os.path.exists(before_scroll_path): os.remove(before_scroll_path) - if os.path.exists(after_scroll_path): os.remove(after_scroll_path) - - if before_scroll_md5 == after_scroll_md5: - logger.info(f"价格列表已到达顶部 (滚动次数: {i})") - break - - logger.info("正在从顶部开始向下逐页截图...") - max_scroll_down_pages = 10 - for p_idx in range(1, max_scroll_down_pages + 1): - p_shot = take_screenshot(d, f"price_scroll_{p_idx}_{station_name}", save_dir=TEMP_IMAGE_DIR) - - before_dn_md5 = Kit.get_image_content_md5(p_shot) - d.swipe_ext("up", scale=0.8) - await asyncio.sleep(0.3) - - check_dn_path = take_screenshot(d, f"check_dn_{p_idx}", save_dir=TEMP_IMAGE_DIR) - after_dn_md5 = Kit.get_image_content_md5(check_dn_path) - if os.path.exists(check_dn_path): os.remove(check_dn_path) - - price_screenshots.append(p_shot) - - if before_dn_md5 == after_dn_md5: - logger.info(f"价格列表已到达底部 (共抓取页数: {p_idx})") - break - - # 后台处理价格图片 - 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) - - # 检查返回后的页面状态 - check_back_path = take_screenshot(d, f"check_back_{detail_uuid}", save_dir=TEMP_IMAGE_DIR) - - check_list_md5 = Kit.get_image_content_md5( - check_back_path, - top_ratio=SAFE_EXCLUDE_RATIO, - bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO - ) - check_detail_md5 = Kit.get_image_content_md5(check_back_path) - - if os.path.exists(check_back_path): os.remove(check_back_path) - - if check_list_md5 == current_md5: - logger.info("检测到已直接返回列表页,跳过后续返回操作。") - should_back_to_list = False - elif check_detail_md5 == before_click_md5: - logger.info("检测到返回详情页,需执行第二次返回。") - - except Exception as e: - logger.error(f"处理三级页面详情异常 ({station_name}): {e}") - - # 从二级页面返回列表页 - if should_back_to_list: - d.press("back") - await asyncio.sleep(0.8) + if click_pos: + logger.info(f"通过 timePrice.jpg 成功找到!坐标: {click_pos}") - # 记录 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) + # 如果模板匹配未成功解析坐标,或者匹配直接失败,则降级到 VL + if not click_pos: + logger.info("模板匹配未找到或解析失败,降级使用 VL 识别...") + res = await ReadImageKit.find_time_price_button_coordinate(detail_path, device_info=device_info) + if res.get("uia_center_x"): + click_pos = (res.get("uia_center_x"), res.get("uia_center_y")) + logger.info(f"VL 识别成功找到 '分时价格' 按钮: {click_pos}") - if FIRST_RUN_ONLY_ONE_STATION: - logger.info("已完成首个场站的全流程采集,根据配置退出艾特吉易充爬取任务。") - break + if click_pos: + click_x, click_y = int(click_pos[0]), int(click_pos[1]) + bottom_safe_y = int(h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)) + if click_y >= bottom_safe_y: + logger.warning(f"'分时价格'按钮识别坐标 ({click_x}, {click_y}) 落在底部安全区域 (>= {bottom_safe_y}),为避免误触底部扫码/导航按钮,本次不点击。") + else: + d.click(click_x, click_y) + await asyncio.sleep(0.5) + + # 检查页面是否真的变化了 + after_click_path = take_screenshot(d, f"check_{detail_uuid}", save_dir=TEMP_IMAGE_DIR) + after_click_md5 = Kit.get_image_content_md5(after_click_path) + + if before_click_md5 != after_click_md5: + entered_price_page = True + price_screenshots = [] + + logger.info("正在向上滚动价格列表到顶部 (快速多次滚动以尽快看到 00:00)...") + max_scroll_up_to_top = 10 + for i in range(max_scroll_up_to_top): + before_scroll_path = take_screenshot(d, f"aite_price_up_{i}", save_dir=TEMP_IMAGE_DIR) + before_scroll_md5 = Kit.get_image_content_md5(before_scroll_path) + + d.swipe_ext("down", scale=0.85) + await asyncio.sleep(0.3) + + after_scroll_path = take_screenshot(d, f"aite_price_up_after_{i}", save_dir=TEMP_IMAGE_DIR) + after_scroll_md5 = Kit.get_image_content_md5(after_scroll_path) + + if os.path.exists(before_scroll_path): os.remove(before_scroll_path) + if os.path.exists(after_scroll_path): os.remove(after_scroll_path) + + if before_scroll_md5 == after_scroll_md5: + logger.info(f"价格列表已到达顶部 (滚动次数: {i})") + break + + logger.info("正在从顶部开始向下逐页截图...") + max_scroll_down_pages = 10 + for p_idx in range(1, max_scroll_down_pages + 1): + p_shot = take_screenshot(d, f"price_scroll_{p_idx}_{station_name}", save_dir=TEMP_IMAGE_DIR) + + before_dn_md5 = Kit.get_image_content_md5(p_shot) + d.swipe_ext("up", scale=0.8) + await asyncio.sleep(0.3) + + check_dn_path = take_screenshot(d, f"check_dn_{p_idx}", save_dir=TEMP_IMAGE_DIR) + after_dn_md5 = Kit.get_image_content_md5(check_dn_path) + if os.path.exists(check_dn_path): os.remove(check_dn_path) + + price_screenshots.append(p_shot) + + if before_dn_md5 == after_dn_md5: + logger.info(f"价格列表已到达底部 (共抓取页数: {p_idx})") + break + + # 后台处理价格图片 + 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) + + # 检查返回后的页面状态 + check_back_path = take_screenshot(d, f"check_back_{detail_uuid}", save_dir=TEMP_IMAGE_DIR) + + check_list_md5 = Kit.get_image_content_md5( + check_back_path, + top_ratio=SAFE_EXCLUDE_RATIO, + bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO + ) + check_detail_md5 = Kit.get_image_content_md5(check_back_path) + + if os.path.exists(check_back_path): os.remove(check_back_path) + + if check_list_md5 == current_md5: + logger.info("检测到已直接返回列表页,跳过后续返回操作。") + should_back_to_list = False + elif check_detail_md5 == before_click_md5: + logger.info("检测到返回详情页,需执行第二次返回。") + + except Exception as e: + logger.error(f"处理三级页面详情异常 ({station_name}): {e}") + + # 从二级页面返回列表页 + if should_back_to_list: + 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) + + if FIRST_RUN_ONLY_ONE_STATION: + logger.info("已完成首个场站的全流程采集,根据配置退出艾特吉易充爬取任务。") + break - # 如果内层循环已达上限,外层循环也应退出,避免不必要的滑动 if total_encountered_count >= max_stations_count: break @@ -334,12 +337,9 @@ async def get_station_list(d, service, max_stations_count=MAX_STATIONS_COUNT): logger.error(f"后台任务异常: {t.exception()}") background_tasks.remove(t) - if not stations: + if new_stations_processed_in_page == 0: no_new_data_count += 1 - logger.info(f"本页未识别到任何有效场站,连续 {no_new_data_count} 页无有效场站/新数据。") - elif new_stations_processed_in_page == 0: - no_new_data_count += 1 - logger.info(f"本页所有场站均已处理过,连续 {no_new_data_count} 页无新数据。") + logger.info(f"本页所有场站均已处理过或被过滤,连续 {no_new_data_count} 页无新数据。") else: no_new_data_count = 0 diff --git a/Apps/AiTeJiYiChong/FirstPageKit.py b/Apps/AiTeJiYiChong/FirstPageKit.py new file mode 100644 index 0000000..3dd665d --- /dev/null +++ b/Apps/AiTeJiYiChong/FirstPageKit.py @@ -0,0 +1,539 @@ +import asyncio +import json +import os +import sys + +import cv2 +import numpy as np + +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 Apps.AiTeJiYiChong.Kit import setup_logger +from Apps.AiTeJiYiChong.ReadImageKit import ReadImageKit +from Apps.AiTeJiYiChong.Config.Setting import SAFE_EXCLUDE_RATIO, BOTTOM_SAFE_EXCLUDE_RATIO, STATION_BOX_WIDTH_RATIO, STATION_BOX_HEIGHT_RATIO +from Util.EasyOcrKit import get_easyocr_reader +from Util.LlmUtil import get_llm_response + + +logger = setup_logger("AiTeJiYiChong.FirstPageKit") +NON_STATION_KEYWORDS = [ + "首页", + "找桩", + "会员", + "我的", + "扫码充电", + "扫码", + "搜索场站或目的地", + "签到", + "优惠券", + "站内搜索", + "筛选", +] +STATION_TITLE_KEYWORDS = [ + "充电站", + "超快充", + "快充", + "换电站", + "加电站", + "超级充电", + "超充", + "充电", +] +TOP_ZONE_STATION_HINT_KEYWORDS = [ + "充电站", + "公共充电", + "超级充电", + "慢充站", + "慢充", + "超快充", + "快充", + "充电", + "超充", +] + +LLM_NON_STATION_NAME_KEYWORDS = [ + "收费停车", + "限时免费停车", + "停车费", +] + + +def _load_image(path): + if not os.path.exists(path): + raise FileNotFoundError(path) + img = cv2.imread(path) + if img is None: + raise RuntimeError(f"无法读取图片: {path}") + h, w = img.shape[:2] + return img, w, h + + +async def run_ocr_rect(image_path, log_path=None): + log_lines = [] + + def log_detail(msg): + logger.info(msg) + log_lines.append(msg) + + img, w, h = _load_image(image_path) + log_detail(f"开始处理图片: {image_path}, 宽={w}, 高={h}") + + reader = get_easyocr_reader(gpu=True) + ocr_results = reader.read_text(img) + log_detail(f"OCR 原始结果数量: {len(ocr_results)}") + + entries = [] + for idx, (quad, text, prob) in enumerate(ocr_results): + pts = np.array(quad).astype(int) + x_min = int(np.min(pts[:, 0])) + y_min = int(np.min(pts[:, 1])) + x_max = int(np.max(pts[:, 0])) + y_max = int(np.max(pts[:, 1])) + cx = (x_min + x_max) / 2.0 + cy = (y_min + y_max) / 2.0 + cx_norm = cx / w + cy_norm = cy / h + + status = "keep" + reasons = [] + if prob < 0.4: + status = "drop" + reasons.append("prob<0.4") + if not text: + status = "drop" + reasons.append("empty_text") + if cy_norm < SAFE_EXCLUDE_RATIO: + status = "drop" + reasons.append("top_safe_zone") + if cy_norm > (1 - BOTTOM_SAFE_EXCLUDE_RATIO): + status = "drop" + reasons.append("bottom_safe_zone") + if status == "keep": + for kw in NON_STATION_KEYWORDS: + if kw and kw in text: + status = "drop" + reasons.append("non_station_keyword") + break + if status == "drop" and "prob<0.4" in reasons and text: + for kw in STATION_TITLE_KEYWORDS: + if kw and kw in text: + status = "keep" + reasons.append("force_keep_station_title") + break + if status == "drop" and "top_safe_zone" in reasons and text: + if SAFE_EXCLUDE_RATIO - 0.06 <= cy_norm < SAFE_EXCLUDE_RATIO: + for kw in TOP_ZONE_STATION_HINT_KEYWORDS: + if kw and kw in text: + status = "keep" + reasons.append("force_keep_top_station_title") + break + + log_detail( + f"OCR[{idx + 1}] text={repr(text)} prob={prob:.3f} " + f"cx_norm={cx_norm:.4f} cy_norm={cy_norm:.4f} " + f"status={status} reasons={','.join(reasons) if reasons else '-'}" + ) + + if status != "keep": + continue + + entries.append( + { + "text": text, + "prob": float(prob), + "cx_norm": cx_norm, + "cy_norm": cy_norm, + } + ) + + log_detail(f"OCR 通过过滤的有效文本数量: {len(entries)}") + + if not entries: + log_detail("无有效 OCR 文本, 结束当前图片处理") + return + + indexed_entries = [] + for idx, e in enumerate(entries): + indexed_entries.append( + { + "id": idx + 1, + "text": e["text"], + "prob": e["prob"], + "cx_norm": round(e["cx_norm"], 4), + "cy_norm": round(e["cy_norm"], 4), + } + ) + + payload_json = json.dumps(indexed_entries, ensure_ascii=False) + log_detail(f"传给 LLM 的 OCR 条目数: {len(indexed_entries)}") + + query_text = ( + "下面是艾特吉易充列表页整张截图的 OCR 结果,每一项代表一行文字,包含其中心点的归一化坐标:\n" + "ocr_items = " + payload_json + "\n" + "请你根据这些文本,将它们聚合成若干个“充电场站卡片”。输出一个 JSON 数组,每个元素必须包含:\n" + "1) station_name: 场站名称,只能是卡片标题中的名称,不允许是筛选标签、导航按钮、底部功能区等。\n" + "2) anchor_point_norm: 一个对象 {\"x\": number, \"y\": number},表示该场站名称文字所在行的中心点坐标,取值范围 0-1。\n" + "并且尽量补充以下可选字段(找不到时可以省略或设为 null):\n" + "3) distance_text: 距离字符串,例如 \"6.9km\"、\"500m\",从对应卡片中的距离行提取。\n" + "4) busy_list: 忙闲信息数组,数组中的每一项是 {\"mode\": \"快|慢|超|普通\", \"idle\": number, \"total\": number}。\n" + " 特别注意:在艾特吉易充 UI 中,忙闲文本通常是 \"快 3/4\"、\"慢 0/0\"、\"超 1/4\" 这种形式,\n" + " 中间并没有“闲”这个字,但语义等价于“当前空闲/总数”,请你仍然解析为 idle/total 数字。\n" + " 例如:\"快 3/4\" => {\"mode\": \"快\", \"idle\": 3, \"total\": 4};\n" + " \"慢 0/0\" => {\"mode\": \"慢\", \"idle\": 0, \"total\": 0};\n" + " \"超 1/4\" => {\"mode\": \"超\", \"idle\": 1, \"total\": 4}。\n" + "额外提示:\n" + "- 每个场站卡片通常包含一行类似 \"1.4km\"、\"3.6km\" 的距离文本;\n" + "- 该距离文本所在行的左侧、且在同一卡片中的那一行文字,就是对应的场站标题 station_name;\n" + "- 忙闲信息通常出现在卡片右侧的彩色小块中,例如 \"快 3/4\"、\"慢 0/0\"、\"超 1/4\";\n" + "- 即使 station_name 中不包含“充电站”“超快充”等字样,但只要与某个 \"x.xkm\" 行在同一卡片区域内,也应视为一个完整的场站名称。\n" + "要求:\n" + "- 场站按从上到下排序;\n" + "- station_name 不能取距离行本身(如 \"1.4km\"),而是要取与之成一对的标题行;\n" + "- 如果某些 OCR 文本显然不属于任何场站卡片,可以忽略;\n" + "- 只输出 JSON 数组,不要输出其它任何文字。" + ) + + chunks = [] + async for part in get_llm_response( + query_text=query_text, + stream=False, + system_prompt="你是一个帮助整理 OCR 文本的助手,只输出 JSON。", + chat_history=None, + temperature=0, + ): + chunks.append(part) + + full_text = "".join(chunks) + log_detail("LLM 原始返回内容开始") + log_lines.append(full_text) + log_detail("LLM 原始返回内容结束") + + raw = ReadImageKit._extract_json(full_text) + log_detail(f"从 LLM 返回内容中抽取出的 JSON 片段: {raw}") + + try: + data = json.loads(raw) + except Exception as e: + log_detail(f"解析 LLM 返回 JSON 失败: {e}") + logs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Logs") + os.makedirs(logs_dir, exist_ok=True) + if log_path is None: + final_log_path = os.path.join( + logs_dir, os.path.basename(image_path).replace(".jpg", ".log") + ) + mode = "w" + else: + final_log_path = log_path + mode = "a" + with open(final_log_path, mode, encoding="utf-8") as f: + for line in log_lines: + f.write(line + "\n") + log_detail(f"已写入详细日志到: {final_log_path}") + return + + if not isinstance(data, list): + log_detail(f"期望 LLM 返回 JSON 数组, 实际类型: {type(data)}") + return + + if not data: + log_detail("LLM 返回空数组, 结束当前图片处理") + return + + stations = [] + for idx, item in enumerate(data): + if not isinstance(item, dict): + log_detail(f"LLM item[{idx}] 不是对象类型, 跳过") + continue + name = item.get("station_name") or item.get("name") + if name: + bad = False + for kw in LLM_NON_STATION_NAME_KEYWORDS: + if kw and kw in name: + log_detail( + f"LLM item[{idx}] station_name 命中非场站关键词 {kw}, 丢弃, name={name}" + ) + bad = True + break + if bad: + continue + anchor = item.get("anchor_point_norm") or item.get("anchor") or item.get("center_norm") + if not anchor: + log_detail(f"LLM item[{idx}] 缺少 anchor 信息, 跳过, content={item}") + continue + try: + ax = float(anchor.get("x")) + ay = float(anchor.get("y")) + except Exception: + log_detail(f"LLM item[{idx}] anchor 不可解析, content={anchor}") + continue + if not (0 <= ax <= 1 and 0 <= ay <= 1): + log_detail( + f"LLM item[{idx}] anchor 超出范围, x={ax}, y={ay}, 跳过" + ) + continue + px = int(ax * w) + py = int(ay * h) - 20 + if py < 0: + py = 0 + + distance_text = item.get("distance_text") or item.get("distance") + busy_list_raw = ( + item.get("busy_list") + or item.get("busyInfos") + or item.get("busy_info") + or item.get("busy") + ) + busy_list = [] + if isinstance(busy_list_raw, list): + for bi in busy_list_raw: + if not isinstance(bi, dict): + continue + mode = bi.get("mode") or bi.get("type") + idle = bi.get("idle") + total = bi.get("total") + try: + idle = int(idle) if idle is not None else None + except Exception: + idle = None + try: + total = int(total) if total is not None else None + except Exception: + total = None + busy_list.append({"mode": mode, "idle": idle, "total": total}) + elif isinstance(busy_list_raw, dict): + mode = busy_list_raw.get("mode") or busy_list_raw.get("type") + idle = busy_list_raw.get("idle") + total = busy_list_raw.get("total") + try: + idle = int(idle) if idle is not None else None + except Exception: + idle = None + try: + total = int(total) if total is not None else None + except Exception: + total = None + busy_list.append({"mode": mode, "idle": idle, "total": total}) + + stations.append( + { + "name": name, + "ax": ax, + "ay": ay, + "px": px, + "py": py, + "distance_text": distance_text, + "busy_list": busy_list, + } + ) + log_detail( + f"LLM anchor 规范化[{len(stations)}] name={name} ax={ax:.4f} ay={ay:.4f} py={py} " + f"distance={distance_text} busy_list={busy_list}" + ) + + if not stations: + log_detail("LLM 解析后没有可用的场站锚点, 结束当前图片处理") + return + + filtered = [] + for s in stations: + if s.get("busy_list"): + filtered.append(s) + else: + log_detail( + f"场站 {s.get('name')} busy_list 为空, 视为信息不完整, 丢弃" + ) + stations = filtered + if not stations: + log_detail("所有场站的忙闲信息均为空, 本页不画绿框") + return + + stations.sort(key=lambda s: s["py"]) + + overlay = img.copy() + results = [] + + box_w = int(w * STATION_BOX_WIDTH_RATIO) + box_h_conf = int(h * STATION_BOX_HEIGHT_RATIO) + box_h = box_h_conf + + if len(stations) >= 2: + gaps = [] + for i in range(len(stations) - 1): + dy = stations[i + 1]["py"] - stations[i]["py"] + if dy > 0: + gaps.append(dy) + if gaps: + min_gap = min(gaps) + max_no_overlap = max(min_gap - 10, 40) + if max_no_overlap < box_h_conf * 0.6: + box_h = box_h_conf + log_detail( + f"场站间距过小(min_gap={min_gap}), 使用配置高度 box_h={box_h_conf}" + ) + else: + box_h = min(box_h_conf, max_no_overlap) + log_detail( + f"根据最小锚点间距调整 box_h: conf={box_h_conf}, " + f"min_gap={min_gap}, final={box_h}" + ) + else: + box_h = box_h_conf + log_detail("未找到有效间距, 使用配置高度 box_h={box_h_conf}") + else: + box_h = box_h_conf + log_detail(f"仅一个场站, 使用配置高度 box_h={box_h_conf}") + + x1_fixed = max(0, (w - box_w) // 2) + x2_fixed = min(w, x1_fixed + box_w) + effective_top = int(h * SAFE_EXCLUDE_RATIO) + effective_bottom = int(h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)) + + log_detail( + f"固定绿框参数: box_w={box_w}, box_h={box_h}, " + f"x1_fixed={x1_fixed}, x2_fixed={x2_fixed}, " + f"effective_top={effective_top}, effective_bottom={effective_bottom}" + ) + + prev_y2 = None + + for idx, s in enumerate(stations): + name = s["name"] + ax = s["ax"] + ay = s["ay"] + px = s["px"] + py = s["py"] + + if py < effective_top: + log_detail( + f"Station[{idx + 1}] {name} 锚点 py={py} 位于顶部保护区之上(effective_top={effective_top}), 丢弃" + ) + continue + + y1 = py - int(box_h * 0.1) + y2 = y1 + box_h + orig_y1 = y1 + orig_y2 = y2 + if y1 < 0: + y1 = 0 + y2 = box_h + if y2 > h: + y2 = h + y1 = h - box_h + + if ay >= SAFE_EXCLUDE_RATIO and y1 < effective_top: + y1 = effective_top + y2 = y1 + box_h + if y2 > h: + y2 = h + y1 = h - box_h + if y2 > effective_bottom: + y2 = effective_bottom + y1 = y2 - box_h + if y1 < 0: + y1 = 0 + y2 = box_h + + if prev_y2 is not None and y1 <= prev_y2: + shift = prev_y2 - y1 + 1 + y1 += shift + y2 += shift + if y2 > effective_bottom: + if idx == len(stations) - 1: + min_height = int(box_h * 0.5) + new_y1 = prev_y2 + 1 + new_y2 = effective_bottom + if new_y2 - new_y1 >= min_height: + y1 = new_y1 + y2 = new_y2 + else: + log_detail( + f"Station[{idx + 1}] {name} 底部剩余空间不足以放置绿框, 被丢弃" + ) + continue + else: + log_detail( + f"Station[{idx + 1}] {name} 因避免重叠无法放入有效区域, 被丢弃" + ) + continue + + prev_y2 = y2 + + click_x = int((x1_fixed + x2_fixed) / 2) + click_y = int((y1 + y2) / 2) + + log_detail( + f"Station[{idx + 1}] name={name} anchor=({ax:.4f},{ay:.4f}) " + f"px={px}, py={py}, box_orig=({orig_y1},{orig_y2}), " + f"box_adj=({y1},{y2}), click=({click_x},{click_y})" + ) + + cv2.rectangle(overlay, (x1_fixed, y1), (x2_fixed, y2), (0, 255, 0), 2) + cv2.circle(overlay, (click_x, click_y), 8, (0, 0, 255), -1) + + results.append( + { + "index": idx + 1, + "station_name": name, + "rect": [x1_fixed, y1, x2_fixed, y2], + "click_point": [click_x, click_y], + "distance_text": s.get("distance_text"), + "busy_list": s.get("busy_list"), + } + ) + + stem, ext = os.path.splitext(image_path) + out_path = f"{stem}_ocr_rect{ext}" + cv2.imwrite(out_path, overlay) + + log_detail(f"输入图片: {image_path}") + log_detail(f"调试输出图片: {out_path}") + log_detail("识别到的场站及矩形坐标如下:") + for item in results: + log_detail(json.dumps(item, ensure_ascii=False)) + + logs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Logs") + os.makedirs(logs_dir, exist_ok=True) + if log_path is None: + final_log_path = os.path.join( + logs_dir, os.path.basename(image_path).replace(".jpg", ".log") + ) + mode = "w" + else: + final_log_path = log_path + mode = "a" + with open(final_log_path, mode, encoding="utf-8") as f: + for line in log_lines: + f.write(line + "\n") + log_detail(f"已写入详细日志到: {final_log_path}") + + return results + + +async def run_batch_in_dir(base_dir, log_path=None): + files = sorted(os.listdir(base_dir)) + jpg_files = [] + for name in files: + lower = name.lower() + if not lower.endswith(".jpg"): + continue + if lower.endswith("_ocr_rect.jpg"): + continue + jpg_files.append(os.path.join(base_dir, name)) + + if not jpg_files: + logger.info(f"目录中未找到待处理的 JPG 文件: {base_dir}") + return + + logger.info(f"即将批量处理 {len(jpg_files)} 张图片:") + for p in jpg_files: + logger.info(f" - {p}") + + for p in jpg_files: + await run_ocr_rect(p, log_path=log_path) + + +if __name__ == "__main__": + base_dir = os.path.dirname(os.path.abspath(__file__)) + asyncio.run(run_batch_in_dir(base_dir)) diff --git a/Apps/AiTeJiYiChong/__pycache__/Crawler.cpython-310.pyc b/Apps/AiTeJiYiChong/__pycache__/Crawler.cpython-310.pyc index b822f38..2c10ea2 100644 Binary files a/Apps/AiTeJiYiChong/__pycache__/Crawler.cpython-310.pyc and b/Apps/AiTeJiYiChong/__pycache__/Crawler.cpython-310.pyc differ diff --git a/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc b/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc new file mode 100644 index 0000000..e433426 Binary files /dev/null and b/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc differ diff --git a/Apps/AiTeJiYiChong/__pycache__/Opener.cpython-310.pyc b/Apps/AiTeJiYiChong/__pycache__/Opener.cpython-310.pyc index a1e3274..38537c3 100644 Binary files a/Apps/AiTeJiYiChong/__pycache__/Opener.cpython-310.pyc and b/Apps/AiTeJiYiChong/__pycache__/Opener.cpython-310.pyc differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_1_1768703644.jpg b/Test/TestFirstPage/Output/aite_firstpage_1_1768703644.jpg new file mode 100644 index 0000000..5e9ef04 Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_1_1768703644.jpg differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_1_1768703644_ocr_rect.jpg b/Test/TestFirstPage/Output/aite_firstpage_1_1768703644_ocr_rect.jpg new file mode 100644 index 0000000..d29c648 Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_1_1768703644_ocr_rect.jpg differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_2_1768703658.jpg b/Test/TestFirstPage/Output/aite_firstpage_2_1768703658.jpg new file mode 100644 index 0000000..76e9bd3 Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_2_1768703658.jpg differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_2_1768703658_ocr_rect.jpg b/Test/TestFirstPage/Output/aite_firstpage_2_1768703658_ocr_rect.jpg new file mode 100644 index 0000000..84050ba Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_2_1768703658_ocr_rect.jpg differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_3_1768703672.jpg b/Test/TestFirstPage/Output/aite_firstpage_3_1768703672.jpg new file mode 100644 index 0000000..9757387 Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_3_1768703672.jpg differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_3_1768703672_ocr_rect.jpg b/Test/TestFirstPage/Output/aite_firstpage_3_1768703672_ocr_rect.jpg new file mode 100644 index 0000000..01a5de5 Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_3_1768703672_ocr_rect.jpg differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_4_1768703687.jpg b/Test/TestFirstPage/Output/aite_firstpage_4_1768703687.jpg new file mode 100644 index 0000000..b773299 Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_4_1768703687.jpg differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_4_1768703687_ocr_rect.jpg b/Test/TestFirstPage/Output/aite_firstpage_4_1768703687_ocr_rect.jpg new file mode 100644 index 0000000..318986a Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_4_1768703687_ocr_rect.jpg differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_5_1768703701.jpg b/Test/TestFirstPage/Output/aite_firstpage_5_1768703701.jpg new file mode 100644 index 0000000..aadb7fa Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_5_1768703701.jpg differ diff --git a/Test/TestFirstPage/Output/aite_firstpage_5_1768703701_ocr_rect.jpg b/Test/TestFirstPage/Output/aite_firstpage_5_1768703701_ocr_rect.jpg new file mode 100644 index 0000000..4d230f8 Binary files /dev/null and b/Test/TestFirstPage/Output/aite_firstpage_5_1768703701_ocr_rect.jpg differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_1_1768700540.jpg b/Test/TestFirstPage/Output/xdt_firstpage_1_1768700540.jpg deleted file mode 100644 index 4557186..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_1_1768700540.jpg and /dev/null differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_1_1768700540_ocr_rect.jpg b/Test/TestFirstPage/Output/xdt_firstpage_1_1768700540_ocr_rect.jpg deleted file mode 100644 index 7a362d1..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_1_1768700540_ocr_rect.jpg and /dev/null differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_2_1768700553.jpg b/Test/TestFirstPage/Output/xdt_firstpage_2_1768700553.jpg deleted file mode 100644 index 7a59420..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_2_1768700553.jpg and /dev/null differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_2_1768700553_ocr_rect.jpg b/Test/TestFirstPage/Output/xdt_firstpage_2_1768700553_ocr_rect.jpg deleted file mode 100644 index 9198d3e..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_2_1768700553_ocr_rect.jpg and /dev/null differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_3_1768700564.jpg b/Test/TestFirstPage/Output/xdt_firstpage_3_1768700564.jpg deleted file mode 100644 index 231fa7d..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_3_1768700564.jpg and /dev/null differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_3_1768700564_ocr_rect.jpg b/Test/TestFirstPage/Output/xdt_firstpage_3_1768700564_ocr_rect.jpg deleted file mode 100644 index 8144019..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_3_1768700564_ocr_rect.jpg and /dev/null differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_4_1768700577.jpg b/Test/TestFirstPage/Output/xdt_firstpage_4_1768700577.jpg deleted file mode 100644 index d71eda7..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_4_1768700577.jpg and /dev/null differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_4_1768700577_ocr_rect.jpg b/Test/TestFirstPage/Output/xdt_firstpage_4_1768700577_ocr_rect.jpg deleted file mode 100644 index 859f93a..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_4_1768700577_ocr_rect.jpg and /dev/null differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_5_1768700588.jpg b/Test/TestFirstPage/Output/xdt_firstpage_5_1768700588.jpg deleted file mode 100644 index b14ab8f..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_5_1768700588.jpg and /dev/null differ diff --git a/Test/TestFirstPage/Output/xdt_firstpage_5_1768700588_ocr_rect.jpg b/Test/TestFirstPage/Output/xdt_firstpage_5_1768700588_ocr_rect.jpg deleted file mode 100644 index be8a179..0000000 Binary files a/Test/TestFirstPage/Output/xdt_firstpage_5_1768700588_ocr_rect.jpg and /dev/null differ diff --git a/Test/TestFirstPage/T_AiTeJiYiChong_FirstPageScrollTest.py b/Test/TestFirstPage/T_AiTeJiYiChong_FirstPageScrollTest.py new file mode 100644 index 0000000..bd6f339 --- /dev/null +++ b/Test/TestFirstPage/T_AiTeJiYiChong_FirstPageScrollTest.py @@ -0,0 +1,186 @@ +import asyncio +import os +import sys +import time +import shutil + +import uiautomator2 as u2 +import cv2 + + +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 Apps.AiTeJiYiChong.Kit import setup_logger, take_screenshot +from Apps.AiTeJiYiChong.Config.Setting import SCROLL_DISTANCE_RATIO, WAIT_AFTER_SCROLL +from Apps.AiTeJiYiChong.FirstPageKit import run_ocr_rect, run_batch_in_dir + + +LOGS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Logs") +os.makedirs(LOGS_DIR, exist_ok=True) +LOG_FILE_PATH = os.path.join(LOGS_DIR, "T_AiTeJiYiChong_FirstPageScrollTest.log") + +logger = setup_logger( + "AiTeJiYiChong.FirstPageScrollTest", log_file=LOG_FILE_PATH, clear_old_log=True +) + + +async def capture_list_page(d, base_dir: str, index: int): + ts = int(time.time()) + image_uuid = f"aite_firstpage_{index}_{ts}" + list_path = take_screenshot(d, image_uuid, save_dir=base_dir) + logger.info(f"[{index}] 列表截图路径: {list_path}") + + try: + await run_ocr_rect(list_path, log_path=LOG_FILE_PATH) + logger.info(f"[{index}] 已根据 OCR+LLM 算法生成首屏绿框调试图。") + except Exception as e: + logger.exception(f"[{index}] 运行 OCR 绿框测试失败: {e}") + + +async def run_scroll_capture(): + logger.info("请先手动打开微信并进入艾特吉易充小程序的场站列表第一页。") + + d = u2.connect() + w, h = d.window_size() + device_info = d.info or {} + device_info["width"] = w + device_info["height"] = h + + logger.info(f"当前设备: {device_info.get('productName')} | 分辨率: {w}x{h}") + + base_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Output") + if os.path.exists(base_dir): + for name in os.listdir(base_dir): + path = os.path.join(base_dir, name) + if os.path.isfile(path) or os.path.islink(path): + os.remove(path) + elif os.path.isdir(path): + shutil.rmtree(path) + os.makedirs(base_dir, exist_ok=True) + + for i in range(1, 6): + await capture_list_page(d, base_dir, i) + if i < 5: + logger.info(f"[{i}] 向上滑动,scale={SCROLL_DISTANCE_RATIO},等待 {WAIT_AFTER_SCROLL} 秒后继续截图。") + d.swipe_ext("up", scale=SCROLL_DISTANCE_RATIO) + await asyncio.sleep(WAIT_AFTER_SCROLL) + + +async def run_batch_local(): + base_dir = os.path.dirname(os.path.abspath(__file__)) + logger.info(f"批量处理目录中的 JPG 文件: {base_dir}") + await run_batch_in_dir(base_dir, log_path=LOG_FILE_PATH) + + +async def run_single_image(image_path: str): + if not image_path: + logger.error("单图模式下需要提供图片路径。") + return + if not os.path.isabs(image_path): + image_path = os.path.abspath(image_path) + if not os.path.exists(image_path): + logger.error(f"指定的图片不存在: {image_path}") + return + logger.info(f"单图 OCR 测试,图片路径: {image_path}") + await run_ocr_rect(image_path, log_path=LOG_FILE_PATH) + + +async def run_output_regression(): + base_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Output") + if not os.path.exists(base_dir): + logger.error(f"离线回归目录不存在: {base_dir}") + return + files = sorted(os.listdir(base_dir)) + jpg_files = [] + for name in files: + lower = name.lower() + if not lower.endswith(".jpg"): + continue + if lower.endswith("_ocr_rect.jpg"): + continue + jpg_files.append(os.path.join(base_dir, name)) + if not jpg_files: + logger.error("离线回归目录中不存在待检测的原始 JPG 截图。") + return + total = len(jpg_files) + ok_count = 0 + for path in jpg_files: + img = cv2.imread(path) + if img is None: + logger.error(f"无法读取图片: {path}") + continue + h, w = img.shape[:2] + logger.info(f"开始离线校验: {path}") + stations = await run_ocr_rect(path, log_path=LOG_FILE_PATH) + if not stations: + logger.error(f"离线校验失败: {path} 未识别到任何场站。") + continue + page_ok = True + for st in stations: + rect = st.get("rect") or [] + if not isinstance(rect, (list, tuple)) or len(rect) < 4: + logger.error(f"场站缺少有效矩形信息: {st}") + page_ok = False + break + x1, y1, x2, y2 = rect[0], rect[1], rect[2], rect[3] + if not (0 <= x1 < x2 <= w and 0 <= y1 < y2 <= h): + logger.error(f"矩形越界: {rect}, 图片尺寸=({w},{h})") + page_ok = False + break + busy_list = st.get("busy_list") or [] + if not busy_list: + logger.error(f"场站忙闲信息为空: {st}") + page_ok = False + break + click_point = st.get("click_point") or st.get("click") or [] + if isinstance(click_point, (list, tuple)) and len(click_point) >= 2: + cx, cy = click_point[0], click_point[1] + if not (x1 <= cx <= x2 and y1 <= cy <= y2): + logger.error(f"点击点不在矩形内部: click={click_point}, rect={rect}") + page_ok = False + break + if page_ok: + ok_count += 1 + logger.info(f"离线校验通过: {path}") + else: + logger.error(f"离线校验未通过: {path}") + if ok_count != total: + raise AssertionError(f"离线首屏 OCR+LLM 校验未全部通过: {ok_count}/{total}") + logger.info(f"离线首屏 OCR+LLM 校验全部通过: {ok_count}/{total}") + + +if __name__ == "__main__": + mode = "scroll" + image_path = None + if len(sys.argv) >= 2: + arg1 = sys.argv[1] + lower = arg1.lower() + if lower == "batch": + mode = "batch" + elif lower == "image": + mode = "image" + if len(sys.argv) >= 3: + image_path = sys.argv[2] + elif lower == "check": + mode = "check" + else: + if any(lower.endswith(ext) for ext in [".jpg", ".jpeg", ".png"]): + mode = "image" + image_path = arg1 + try: + if mode == "batch": + asyncio.run(run_batch_local()) + elif mode == "image": + asyncio.run(run_single_image(image_path)) + elif mode == "check": + asyncio.run(run_output_regression()) + else: + asyncio.run(run_scroll_capture()) + except KeyboardInterrupt: + logger.info("用户手动中断测试脚本。") + except Exception as e: + logger.exception(f"测试脚本运行异常: {e}") +