From 7b4548c12cd93dc652b106c74b34a3829bf8be02 Mon Sep 17 00:00:00 2001 From: HuangHai <10402852@qq.com> Date: Mon, 12 Jan 2026 08:26:13 +0800 Subject: [PATCH] 'commit' --- .../__pycache__/Setting.cpython-310.pyc | Bin 0 -> 533 bytes Apps/XinDianTu/Crawler.py | 249 ++++++----- Apps/XinDianTu/Kit.py | 412 +++++++++--------- Apps/XinDianTu/Opener.py | 11 +- Apps/XinDianTu/Run.py | 31 -- Apps/XinDianTu/Service.py | 10 +- Apps/XinDianTu/XinDianTuReadImageKit.py | 207 +++++---- .../__pycache__/Crawler.cpython-310.pyc | Bin 14981 -> 15040 bytes .../XinDianTu/__pycache__/Kit.cpython-310.pyc | Bin 0 -> 17525 bytes .../__pycache__/Opener.cpython-310.pyc | Bin 4682 -> 4463 bytes .../XinDianTu/__pycache__/Run.cpython-310.pyc | Bin 3188 -> 2178 bytes .../__pycache__/Service.cpython-310.pyc | Bin 10769 -> 10690 bytes .../XinDianTuReadImageKit.cpython-310.pyc | Bin 0 -> 35029 bytes 13 files changed, 471 insertions(+), 449 deletions(-) create mode 100644 Apps/XinDianTu/Config/__pycache__/Setting.cpython-310.pyc create mode 100644 Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc create mode 100644 Apps/XinDianTu/__pycache__/XinDianTuReadImageKit.cpython-310.pyc diff --git a/Apps/XinDianTu/Config/__pycache__/Setting.cpython-310.pyc b/Apps/XinDianTu/Config/__pycache__/Setting.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23fe7e24d5ac0022bb3de4d7f4ba4cac22f25f33 GIT binary patch literal 533 zcmZuuNlwEs6m{FQq!Wmt5*(o$B!&e-h}Ohz8<93uV$|{#$yE9!ilBo!NN|*L1CGEE zShMSvRd0Y;;e-fS@GL+7@qNo46^l8daCeHqB2CD9==?=OIaRf%$BK{u!2~2Q2}w*r z3N_F$4Qb3k2G?K>biq=aKT$qn9*w&N*!tn$+)1VCEK*lrDanFn^E|J8kbVo z(o*x|5UmOB#KV_cF0FATZ&+6>zT9%}!WP1A%70SpHELOrX2)crvE2Ca8{Din#50-b z>ZKz3JYc6DPCKK?o!1LkZ`SkZcs%vG!H@;Lp_qHdXm}g=p4*?zf}y`Zeq8EjgAvZ} X`{&`8PItpcPie_$r8F%%Bi55&Y5|9K literal 0 HcmV?d00001 diff --git a/Apps/XinDianTu/Crawler.py b/Apps/XinDianTu/Crawler.py index 2d6e428..c3cb9e6 100644 --- a/Apps/XinDianTu/Crawler.py +++ b/Apps/XinDianTu/Crawler.py @@ -16,23 +16,26 @@ if project_root not in sys.path: import uiautomator2 as u2 from Core.BaseCrawler import BaseCrawler -from Util import Kit -from Util.Kit import take_screenshot +from Apps.XinDianTu import Kit +from Apps.XinDianTu.Kit import take_screenshot from Util.ObsUtil import ObsUploader from Util.RedisKit import RedisKit from Util.PaddleOCRKit import get_ocr_kit import cv2 from Apps.XinDianTu.Service import XinDianTuService from Config.Config import ( - OBS_TMP_PREFIX, CDN_DOMAIN, SCROLL_DISTANCE_RATIO, + OBS_TMP_PREFIX, CDN_DOMAIN, TEMP_IMAGE_DIR +) +from Apps.XinDianTu.Config.Setting import ( + SCROLL_DISTANCE_RATIO, MAX_SCROLLS, REDIS_STATION_EXPIRE, WAIT_DETAIL_PAGE_LOAD, WAIT_BACK_TO_LIST, WAIT_AFTER_SCROLL, - MAX_CRAWL_DISTANCE, TEMP_IMAGE_DIR + MAX_CRAWL_DISTANCE ) # --- 用户配置区域 --- # 是否保留截图文件(True=保留备查,False=随用随删) -KEEP_SCREENSHOTS = True +KEEP_SCREENSHOTS = True # [Testing] 是否在启动时清除 Redis 中的场站处理记录 # True: 每次运行前清除记录,方便反复测试同一个场站 @@ -57,14 +60,15 @@ logger = logging.getLogger("StationList") # 强制设置所有相关模块的日志级别为 INFO,防止被第三方库干扰 logging.getLogger("PaddleOCRKit").setLevel(logging.INFO) -logging.getLogger("Util.Kit").setLevel(logging.INFO) logging.getLogger("OpenXinDianTu").setLevel(logging.INFO) logging.getLogger("FullProcess").setLevel(logging.INFO) + class XinDianTuCrawler(BaseCrawler): """ 新电途小程序爬虫实现 """ + def __init__(self, service=None): super().__init__(service) # 初始化配置参数 @@ -90,6 +94,7 @@ class XinDianTuCrawler(BaseCrawler): async def crawl_detail(self, station_info): pass + async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): """ 获取场站列表并处理翻页 @@ -97,21 +102,21 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): all_stations = [] seen_names = set() redis_kit = RedisKit() - + # 确保在“找桩”或者首页列表界面 window_size = d.window_size() w, h = window_size[0], window_size[1] - + # 获取设备信息以用于坐标计算 device_info = d.info device_info['width'] = w device_info['height'] = h - + logger.info(f"设备信息: {device_info.get('productName')} | 窗口分辨率: {w}x{h}") - + max_distance_reached = False stop_reason = "max_scrolls_reached" # 默认停止原因:达到最大翻页次数 - + # 存储后台异步任务 background_tasks = [] @@ -121,7 +126,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): stop_reason = "max_distance_reached" break logger.info(f"正在处理第 {i + 1} 页...") - + # 1. 拍摄截图 logger.info(f"Step [1/6] 正在拍摄列表页截图...") t_shot = time.time() @@ -131,7 +136,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): save_dir = TEMP_IMAGE_DIR screenshot_path = take_screenshot(d, image_uuid, save_dir=save_dir) logger.info(f"Step [1/6] 列表页截图已完成: {screenshot_path} (耗时: {time.time() - t_shot:.2f}s)") - + # 校验截图实际尺寸 img_w, img_h = 0, 0 try: @@ -140,115 +145,119 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): logger.info(f"截图实际尺寸: {img_w}x{img_h}") # 如果截图尺寸与窗口尺寸不符,更新 device_info 以适配坐标换算 if img_w != w or img_h != h: - logger.warning(f"检测到截图尺寸 ({img_w}x{img_h}) 与窗口尺寸 ({w}x{h}) 不一致,将以截图尺寸为准进行坐标换算。") + logger.warning( + f"检测到截图尺寸 ({img_w}x{img_h}) 与窗口尺寸 ({w}x{h}) 不一致,将以截图尺寸为准进行坐标换算。") device_info['width'] = img_w device_info['height'] = img_h except Exception as e: logger.warning(f"无法获取截图尺寸信息: {e}") - + # 2. 使用 Kit.crop_cards_from_image 进行切片和生成 _vl.jpg logger.info("Step [2/6] 正在执行本地图形学切片分析,识别场站卡片并生成绿色方框图...") t_crop = time.time() # 这会生成 _vl.jpg 和 .json Kit.crop_cards_from_image(screenshot_path) logger.info(f"Step [2/6] 图形学切片分析完成 (耗时: {time.time() - t_crop:.2f}s)") - + # 读取生成的元数据 json_path = screenshot_path.replace(".jpg", ".json") vl_img_path = screenshot_path.replace(".jpg", "_vl.jpg") - + if not os.path.exists(json_path) or not os.path.exists(vl_img_path): - logger.error(f"❌ Step [2/6] 失败: 未生成有效的 JSON 或 VL 图片 (JSON: {os.path.exists(json_path)}, VL: {os.path.exists(vl_img_path)})") + logger.error( + f"❌ Step [2/6] 失败: 未生成有效的 JSON 或 VL 图片 (JSON: {os.path.exists(json_path)}, VL: {os.path.exists(vl_img_path)})") logger.warning("可能未识别到任何卡片,跳过当前页") continue - + logger.info(f"Step [3/6] 绿色方框图已生成: {vl_img_path}") - + with open(json_path, 'r', encoding='utf-8') as f: json_metadata = json.load(f) - + # --- New Logic: Local OCR via PaddleOCRKit --- logger.info("Step [4/6] 正在开始本地 OCR 识别流程,将识别出的卡片逐一交给 OCR...") t_ocr_total = time.time() - + stations = [] try: ocr_kit = get_ocr_kit() - + # 读取原始截图进行裁剪 (比读取 _vl.jpg 更干净,没有绿框干扰) original_img = cv2.imread(screenshot_path) - + if "cards" in json_metadata and original_img is not None: h_img, w_img = original_img.shape[:2] num_cards = len(json_metadata['cards']) logger.info(f"检测到 {num_cards} 个场站卡片,准备开始逐一 OCR 识别...") - + for idx, card in enumerate(json_metadata["cards"]): # card: {"id": 1, "rect": [x1, y1, x2, y2], "click_point": [cx, cy]} rect = card.get("rect") if not rect: continue x1, y1, x2, y2 = rect - + # 边界检查 x1 = max(0, min(x1, w_img)) x2 = max(0, min(x2, w_img)) y1 = max(0, min(y1, h_img)) y2 = max(0, min(y2, h_img)) - + if x2 <= x1 or y2 <= y1: continue - + # 裁剪卡片 cropped_card = original_img[y1:y2, x1:x2] - + # 识别 - logger.info(f"正在识别第 {idx+1}/{num_cards} 个卡片 (区域: {rect})...") + logger.info(f"正在识别第 {idx + 1}/{num_cards} 个卡片 (区域: {rect})...") t_card_start = time.time() parsed_data = ocr_kit.recognize(cropped_card) t_card_end = time.time() - + # Log detailed OCR result - logger.info(f"卡片 {idx+1} OCR 识别耗时: {t_card_end - t_card_start:.2f}s") - + logger.info(f"卡片 {idx + 1} OCR 识别耗时: {t_card_end - t_card_start:.2f}s") + if parsed_data and parsed_data.get("station_name"): - # 格式化数据以匹配原有结构 - piles_list = parsed_data.get("piles", []) - piles_str_parts = [] - for p in piles_list: - p_type = p.get("type", "") - p_free = p.get("free", 0) - p_total = p.get("total", 0) - piles_str_parts.append(f"{p_type}:{p_free}/{p_total}") - - piles_str = " ".join(piles_str_parts) - - station_info = { - "station_name": parsed_data.get("station_name"), - "price": str(parsed_data.get("price")) if parsed_data.get("price") is not None else "", - "pro_price": "", # 暂未解析 - "piles": piles_str, - "distance": parsed_data.get("distance", ""), - "uia_center_x": card["click_point"][0], - "uia_center_y": card["click_point"][1], - "tags": parsed_data.get("tags", []), - "parking_info": parsed_data.get("parking", "") - } - logger.info(f"✅ 成功解析场站: {station_info['station_name']} | 距离: {station_info['distance']}") - - # 立即记录场站基础状态信息 (电桩、价格等) - try: - await service.record_station_status(station_info) - except Exception as e: - logger.error(f"❌ 记录场站状态失败: {e}") - - stations.append(station_info) + # 格式化数据以匹配原有结构 + piles_list = parsed_data.get("piles", []) + piles_str_parts = [] + for p in piles_list: + p_type = p.get("type", "") + p_free = p.get("free", 0) + p_total = p.get("total", 0) + piles_str_parts.append(f"{p_type}:{p_free}/{p_total}") + + piles_str = " ".join(piles_str_parts) + + station_info = { + "station_name": parsed_data.get("station_name"), + "price": str(parsed_data.get("price")) if parsed_data.get("price") is not None else "", + "pro_price": "", # 暂未解析 + "piles": piles_str, + "distance": parsed_data.get("distance", ""), + "uia_center_x": card["click_point"][0], + "uia_center_y": card["click_point"][1], + "tags": parsed_data.get("tags", []), + "parking_info": parsed_data.get("parking", "") + } + logger.info( + f"✅ 成功解析场站: {station_info['station_name']} | 距离: {station_info['distance']}") + + # 立即记录场站基础状态信息 (电桩、价格等) + try: + await service.record_station_status(station_info) + except Exception as e: + logger.error(f"❌ 记录场站状态失败: {e}") + + stations.append(station_info) else: - logger.warning(f"⚠️ 卡片 {idx+1} 未能识别出有效场站名称") - + logger.warning(f"⚠️ 卡片 {idx + 1} 未能识别出有效场站名称") + except Exception as e: logger.error(f"❌ Local OCR processing failed: {e}", exc_info=True) - - logger.info(f"Step [5/6] 本地 OCR 解析完成 (总耗时: {time.time() - t_ocr_total:.2f}s) | 成功识别: {len(stations)}/{len(json_metadata.get('cards', []))}") - + + logger.info( + f"Step [5/6] 本地 OCR 解析完成 (总耗时: {time.time() - t_ocr_total:.2f}s) | 成功识别: {len(stations)}/{len(json_metadata.get('cards', []))}") + if not stations: logger.warning("当前页面未检测到任何有效的场站信息") else: @@ -256,25 +265,25 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): new_stations_found = False for s_idx, s in enumerate(stations): name = s.get("station_name") - + # 过滤掉明显的非场站名称 if not name: continue - + # --- 短期去重:检查 Redis 缓存 --- # 2分钟内如果处理过该场站,则跳过 redis_key = f"processed_station:{name}" if await redis_kit.exists(redis_key): logger.info(f"场站 {name} 在 2 分钟内已处理过,跳过") continue - + if name in seen_names: continue # 使用从 JSON 元数据中恢复的点击坐标 x = s.get("uia_center_x") y = s.get("uia_center_y") - + if x is None or y is None: logger.warning(f"场站 {name} 缺少坐标信息,无法进入详情页") continue @@ -282,7 +291,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): seen_names.add(name) all_stations.append(s) new_stations_found = True - + # 打印基本信息 price = s.get("price") pro_price = s.get("pro_price") @@ -299,20 +308,22 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): dist_val = float(dist_match.group(1)) is_km = "km" in distance_str.lower() dist_km = dist_val if is_km else dist_val / 1000.0 - + # 将距离解析结果和限制检查合并到日志中 dist_log_part = f" (解析: {dist_km:.2f}km / 限: {MAX_CRAWL_DISTANCE}km)" if dist_km >= MAX_CRAWL_DISTANCE: - logger.info(f"当前场站 {name} 距离 {dist_km}km (原始: {distance_str}) 已达到或超过限制 {MAX_CRAWL_DISTANCE}km。") + logger.info( + f"当前场站 {name} 距离 {dist_km}km (原始: {distance_str}) 已达到或超过限制 {MAX_CRAWL_DISTANCE}km。") max_distance_reached = True except Exception as e: logger.warning(f"解析距离出错: {distance_str}, {e}") - + pro_info = f" | PRO会员价: {pro_price}" if pro_price else "" # 确保距离始终显示,如果为空则显示 "未知" display_distance = distance_str if distance_str else "未知" - logger.info(f"正在处理第 {s_idx+1} 个场站: {name} | 价格: {price}{pro_info} | 枪: {piles_str} | 距离: {display_distance}{dist_log_part}") + logger.info( + f"正在处理第 {s_idx + 1} 个场站: {name} | 价格: {price}{pro_info} | 枪: {piles_str} | 距离: {display_distance}{dist_log_part}") if max_distance_reached: break @@ -328,7 +339,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): logger.info(f"👉 正在点击进入详情页: {name} (绝对坐标: {x}, {y})") d.click(x, y) - await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD) # 等待详情页加载 + await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD) # 等待详情页加载 # 尝试获取并保存地址 address_recorded = False @@ -338,21 +349,23 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): detail_uuid = str(uuid.uuid4()) detail_screenshot_path = take_screenshot(d, detail_uuid, save_dir=save_dir) logger.info(f"Step [详情页截图] 耗时: {time.time() - t_d_shot:.4f}s") - + # 2. 上传至 OBS t_d_up = time.time() detail_object_key = f"{OBS_TMP_PREFIX}/{detail_uuid}.jpg" up_success, _ = uploader.upload_file(detail_object_key, detail_screenshot_path) logger.info(f"Step [上传详情页截图] 耗时: {time.time() - t_d_up:.4f}s") - + if up_success: detail_url = f"https://{CDN_DOMAIN}/{detail_object_key}" # 3. 解析并保存地址 (异步后台处理) + # 定义异步任务函数 async def _process_address_task(task_name, task_url, task_dev_info, task_redis_key): t_addr_task = time.time() try: - addr_res = await service.process_station_address(task_name, task_url, device_info=task_dev_info) + addr_res = await service.process_station_address(task_name, task_url, + device_info=task_dev_info) logger.info(f"Step [解析并保存地址-后台] 耗时: {time.time() - t_addr_task:.4f}s") if addr_res.get("address"): logger.info(f"成功记录地址: {addr_res.get('address')}") @@ -368,40 +381,41 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): # 标记为已记录以跳出 retry 循环 address_recorded = True - + # 清理详情页临时截图 if not KEEP_SCREENSHOTS and os.path.exists(detail_screenshot_path): os.remove(detail_screenshot_path) - - break # 直接跳出 retry 循环 + + break # 直接跳出 retry 循环 # --- 抓取价格时段信息 (New Feature) --- # 仅在测试模式下或特定需求下启用 try: logger.info(f"开始抓取价格时段信息: {name}") - + # 使用几何特征识别 "全部时段" 按钮 # 临时截图 temp_uuid = "temp_find_expand" screenshot_path = take_screenshot(d, temp_uuid, save_dir=TEMP_IMAGE_DIR) - + # 尝试识别,将调试图片保存到 Images 目录 t_find = time.time() - pos = Kit.find_expand_button_position(screenshot_path, debug_dir=save_dir, debug_filename_prefix=name) + pos = Kit.find_expand_button_position(screenshot_path, debug_dir=save_dir, + debug_filename_prefix=name) logger.info(f"Step [识别展开按钮] 耗时: {time.time() - t_find:.4f}s") - + # 清理截图 try: os.remove(screenshot_path) except: pass - + if pos: x, y = pos logger.info(f"通过几何特征找到 '全部时段' 按钮: ({x}, {y})") d.click(x, y) await asyncio.sleep(1.5) - + # 抓取3屏截图 price_image_urls = [] for p_idx in range(1, 4): @@ -411,7 +425,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): p_uuid = f"{name_md5}_price_{p_idx}" p_path = take_screenshot(d, p_uuid, save_dir=save_dir) logger.info(f"已保存价格时段截图 {p_idx} (Station: {name}, MD5: {name_md5}): {p_path}") - + # 上传到 OBS p_object_key = f"{OBS_TMP_PREFIX}/{p_uuid}.jpg" success, _ = uploader.upload_file(p_object_key, p_path) @@ -427,15 +441,16 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): if p_idx < 3: d.swipe_ext("up", scale=0.7) await asyncio.sleep(1.0) - + # 调用服务解析价格时段 (异步后台处理) if price_image_urls: logger.info(f"正在调用接口解析电费时段数据 ({len(price_image_urls)} 张图片) - 转入后台...") - + async def _process_price_task(task_name, task_urls, task_dev_info): t_price_task = time.time() try: - await service.process_price_schedule(task_name, task_urls, device_info=task_dev_info) + await service.process_price_schedule(task_name, task_urls, + device_info=task_dev_info) logger.info(f"Step [解析电费时段数据-后台] 耗时: {time.time() - t_price_task:.4f}s") logger.info(f"电费时段数据解析完成: {task_name}") except Exception as e: @@ -447,7 +462,7 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): logger.warning("未能获取到有效的价格时段截图,跳过解析") else: logger.warning(f"未能通过几何特征识别 '全部时段' 按钮") - + except Exception as e: logger.warning(f"抓取价格时段信息时发生异常: {e}") @@ -457,8 +472,8 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): # 返回列表页 d.press("back") - await asyncio.sleep(WAIT_BACK_TO_LIST) # 等待列表页重新稳定 - + await asyncio.sleep(WAIT_BACK_TO_LIST) # 等待列表页重新稳定 + if not new_stations_found and i > 0: logger.info("未发现更多新场站,停止下拉。") # 如果需要强行爬取所有,可以注释掉 break @@ -472,17 +487,17 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): start_y = 0.5 + (SCROLL_DISTANCE_RATIO / 2) end_y = 0.5 - (SCROLL_DISTANCE_RATIO / 2) d.swipe(w * 0.5, h * start_y, w * 0.5, h * end_y, duration=0.5) - await asyncio.sleep(WAIT_AFTER_SCROLL) # 等待页面加载和列表稳定 - + await asyncio.sleep(WAIT_AFTER_SCROLL) # 等待页面加载和列表稳定 + # 清理列表页截图 if not KEEP_SCREENSHOTS: if os.path.exists(screenshot_path): - os.remove(screenshot_path) + os.remove(screenshot_path) if os.path.exists(vl_img_path): - os.remove(vl_img_path) + os.remove(vl_img_path) if os.path.exists(json_path): - os.remove(json_path) - + os.remove(json_path) + logger.info(f"爬取任务结束,正在等待 {len(background_tasks)} 个后台处理任务完成...") if background_tasks: await asyncio.gather(*background_tasks) @@ -491,22 +506,23 @@ async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS): logger.info(f"任务结束,共采集到 {len(all_stations)} 个场站。停止原因: {stop_reason}") return all_stations + def clean_images_dir(): """清理 Images 目录下除保留文件外的所有文件""" base_dir = os.path.dirname(os.path.abspath(__file__)) images_dir = os.path.join(base_dir, "Images") - + if not os.path.exists(images_dir): return keep_files = {'1.jpg', '2.jpg', '3.jpg', '4.jpg'} logger.info(f"正在清理 Images 目录 (保留: {keep_files})...") - + deleted_count = 0 for filename in os.listdir(images_dir): if filename in keep_files: continue - + file_path = os.path.join(images_dir, filename) try: if os.path.isfile(file_path): @@ -514,9 +530,10 @@ def clean_images_dir(): deleted_count += 1 except Exception as e: logger.warning(f"删除文件失败 {filename}: {e}") - + logger.info(f"清理完成,共删除 {deleted_count} 个文件。") + async def clean_redis_data(redis_kit): """清除 Redis 中的场站处理记录""" if TEST_CLEAR_REDIS: @@ -528,10 +545,11 @@ async def clean_redis_data(redis_kit): else: logger.info("未发现旧的场站处理记录") + async def main(service=None, do_cleanup=True): # 连接设备 d = u2.connect() - + # 初始化服务 should_close_service = False if service is None: @@ -542,28 +560,28 @@ async def main(service=None, do_cleanup=True): uploader = ObsUploader() redis_kit = RedisKit() - + try: if do_cleanup: # 清理图片目录 clean_images_dir() - + # [Testing] 如果配置为测试模式,启动时清除 Redis 记录 await clean_redis_data(redis_kit) # 清理过期数据 # await service.cleanup_old_data() - + # 获取场站列表 stations = await get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS) - + if stations: logger.info("场站列表采集完成。") else: logger.warning("未采集到任何场站信息。") - + return True - + except Exception as e: logger.exception(f"运行过程中出现异常: {e}") return False @@ -572,6 +590,7 @@ async def main(service=None, do_cleanup=True): if should_close_service: await service.close_db() + if __name__ == "__main__": try: asyncio.run(main()) diff --git a/Apps/XinDianTu/Kit.py b/Apps/XinDianTu/Kit.py index 67aa0cd..2d5cdb7 100644 --- a/Apps/XinDianTu/Kit.py +++ b/Apps/XinDianTu/Kit.py @@ -3,11 +3,13 @@ import os import cv2 import numpy as np import time -from Config.Config import BOTTOM_SAFE_EXCLUDE_RATIO, TEMP_IMAGE_DIR +from Apps.XinDianTu.Config.Setting import BOTTOM_SAFE_EXCLUDE_RATIO +from Config.Config import TEMP_IMAGE_DIR logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) + def read_image(path): """读取图片,支持中文路径""" try: @@ -16,6 +18,7 @@ def read_image(path): logger.info(f"Error reading image {path}: {e}") return None + def save_image(path, img): """保存图片,支持中文路径""" try: @@ -28,13 +31,15 @@ def save_image(path, img): logger.error(f"Error saving image {path}: {e}") return False + # 截图 -def take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR): - path = f"{save_dir}/{image_uuid}.jpg" +def take_screenshot(d, image_uuid, save_dir=TEMP_IMAGE_DIR): + path = f"{save_dir}/{image_uuid}.jpg" os.makedirs(save_dir, exist_ok=True) - d.screenshot(path) + d.screenshot(path) return path + def click_image_template(d, template_path, timeout=5.0, threshold=0.8): """ 使用 OpenCV 模板匹配查找并点击图片 @@ -52,22 +57,22 @@ def click_image_template(d, template_path, timeout=5.0, threshold=0.8): if template is None: logger.info(f"Failed to load template: {template_path}") return False - + t_h, t_w = template.shape[:2] - + start_time = time.time() best_val_overall = 0.0 - + while time.time() - start_time < timeout: # 临时截图 temp_uuid = "temp_click_check" screenshot_path = take_screenshot(d, temp_uuid, save_dir=TEMP_IMAGE_DIR) - + target = read_image(screenshot_path) if target is None: time.sleep(0.5) continue - + # 多尺度匹配 found = None # 缩放比例从 0.5 到 1.5,步长 0.1 @@ -75,43 +80,44 @@ def click_image_template(d, template_path, timeout=5.0, threshold=0.8): # 调整模板大小 resized_template = cv2.resize(template, (int(t_w * scale), int(t_h * scale))) r_h, r_w = resized_template.shape[:2] - + # 如果模板比目标还大,跳过 if r_h > target.shape[0] or r_w > target.shape[1]: continue - + result = cv2.matchTemplate(target, resized_template, cv2.TM_CCOEFF_NORMED) min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) - + if max_val > best_val_overall: best_val_overall = max_val - + if max_val >= threshold: found = (max_val, max_loc, r_w, r_h) - break # 找到满足阈值的即停止当前截图的搜索 - + break # 找到满足阈值的即停止当前截图的搜索 + # 清理临时文件 try: os.remove(screenshot_path) except: pass - + if found: max_val, max_loc, r_w, r_h = found # 计算中心点 top_left = max_loc center_x = top_left[0] + r_w // 2 center_y = top_left[1] + r_h // 2 - + logger.info(f"Found image at ({center_x}, {center_y}) with confidence {max_val:.2f}") d.click(center_x, center_y) return True - + time.sleep(1.0) - + logger.info(f"Image not found after {timeout}s (Best confidence: {best_val_overall:.2f})") return False + def detect_black_agree_button(image_path, debug_dir=None): """ 通过计算机图形学检测黑色的"同意"按钮 (Image 1 场景) @@ -121,79 +127,80 @@ def detect_black_agree_button(image_path, debug_dir=None): """ if not os.path.exists(image_path): return None - + img = read_image(image_path) if img is None: return None - + h, w = img.shape[:2] - + # 转换为HSV颜色空间,因为黑色更容易过滤 hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) - + # 定义黑色的范围 # 黑色:V (Brightness) 很低 lower_black = np.array([0, 0, 0]) - upper_black = np.array([180, 255, 40]) # V < 40 认为是黑色 - + upper_black = np.array([180, 255, 40]) # V < 40 认为是黑色 + mask = cv2.inRange(hsv, lower_black, upper_black) - + # 限制搜索区域:通常在屏幕下半部分 roi_top = int(h * 0.4) roi_bottom = int(h * 0.8) roi_mask = np.zeros_like(mask) roi_mask[roi_top:roi_bottom, :] = mask[roi_top:roi_bottom, :] - + # 形态学操作:去除噪点,连接断开的区域 kernel = np.ones((5, 5), np.uint8) roi_mask = cv2.morphologyEx(roi_mask, cv2.MORPH_CLOSE, kernel) roi_mask = cv2.morphologyEx(roi_mask, cv2.MORPH_OPEN, kernel) - + # 查找轮廓 contours, _ = cv2.findContours(roi_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - + best_cnt = None max_area = 0 - + for cnt in contours: area = cv2.contourArea(cnt) x, y, cw, ch = cv2.boundingRect(cnt) aspect_ratio = float(cw) / ch - + # 过滤条件 # 1. 面积要够大 (例如 > 屏幕面积的 1%) if area < (w * h * 0.01): continue - + # 2. 宽高比:通常按钮是扁长条,例如 > 2.0 if aspect_ratio < 2.0 or aspect_ratio > 10.0: continue - + # 3. 宽度:通常占据屏幕宽度的 50% 以上 if cw < (w * 0.5): continue - + if area > max_area: max_area = area best_cnt = cnt - + if best_cnt is not None: x, y, cw, ch = cv2.boundingRect(best_cnt) center_x = x + cw // 2 center_y = y + ch // 2 logger.info(f"Found Black Agree Button at ({center_x}, {center_y}), Size: {cw}x{ch}") - + if debug_dir: os.makedirs(debug_dir, exist_ok=True) debug_img = img.copy() - cv2.rectangle(debug_img, (x, y), (x+cw, y+ch), (0, 0, 255), 2) + cv2.rectangle(debug_img, (x, y), (x + cw, y + ch), (0, 0, 255), 2) cv2.circle(debug_img, (center_x, center_y), 5, (0, 255, 0), -1) save_image(os.path.join(debug_dir, "debug_agree_btn.jpg"), debug_img) - + return (center_x, center_y) - + return None + def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7): """ 通过模板匹配检测"关闭(X)"按钮 (Image 2 场景) @@ -206,139 +213,145 @@ def detect_ad_close_x(image_path, template_path, debug_dir=None, threshold=0.7): """ if not os.path.exists(image_path): return None - + if not os.path.exists(template_path): logger.warning(f"Template not found: {template_path}") return None target = read_image(image_path) template = read_image(template_path) - + if target is None or template is None: return None - + # 转换为灰度图进行匹配,减少颜色干扰 target_gray = cv2.cvtColor(target, cv2.COLOR_BGR2GRAY) template_gray = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY) - + t_h, t_w = template_gray.shape[:2] - + best_match = None - + # 多尺度匹配: 缩放模板 # 假设模板可能比实际大,也可能小。范围 0.5 - 1.5 scales = np.linspace(0.5, 1.5, 20) - + for scale in scales: # 计算缩放后的模板尺寸 new_w = int(t_w * scale) new_h = int(t_h * scale) - + # 确保缩放后的模板不大于目标图像 if new_w > target_gray.shape[1] or new_h > target_gray.shape[0]: continue - + resized_template = cv2.resize(template_gray, (new_w, new_h)) - + # 匹配 result = cv2.matchTemplate(target_gray, resized_template, cv2.TM_CCOEFF_NORMED) min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) - + if best_match is None or max_val > best_match[0]: best_match = (max_val, max_loc, scale, new_w, new_h) - + if best_match is None: return None - + max_val, max_loc, best_scale, best_w, best_h = best_match - + # 获取目标图像尺寸 target_h, target_w = target_gray.shape[:2] - + top_left = max_loc center_x = top_left[0] + best_w // 2 center_y = top_left[1] + best_h // 2 - + # --- 位置启发式过滤 --- # 计算相对位置 rel_x = center_x / target_w rel_y = center_y / target_h - - logger.info(f"Ad Close Button Match: Confidence={max_val:.4f}, Scale={best_scale:.2f}, Pos=({center_x}, {center_y}), Rel=({rel_x:.2f}, {rel_y:.2f})") - + + logger.info( + f"Ad Close Button Match: Confidence={max_val:.4f}, Scale={best_scale:.2f}, Pos=({center_x}, {center_y}), Rel=({rel_x:.2f}, {rel_y:.2f})") + is_valid_pos = True - + # 规则1: 过滤掉屏幕正中央偏上的区域 (通常是广告标题、图标或内容) # 范围: X在 [0.3, 0.7] 且 Y在 [0.15, 0.5] if 0.3 < rel_x < 0.7 and 0.15 < rel_y < 0.5: logger.warning(f"Ignored match at ({center_x}, {center_y}) - likely Ad Content/Title (Center-Top area).") is_valid_pos = False - + # 规则2: 如果置信度不是特别高 (>0.9), 强制要求在典型区域 (右上角 或 底部中间) # 右上角: X > 0.7, Y < 0.5 # 底部中间: Y > 0.6 - if is_valid_pos and max_val < 0.98: # 如果置信度非常高(0.98+),可能是极其标准的X,暂时放过(除非在禁区) + if is_valid_pos and max_val < 0.98: # 如果置信度非常高(0.98+),可能是极其标准的X,暂时放过(除非在禁区) if not ((rel_x > 0.7 and rel_y < 0.5) or (rel_y > 0.6)): - logger.warning(f"Ignored match at ({center_x}, {center_y}) - not in typical Close Button regions (Top-Right or Bottom).") - is_valid_pos = False + logger.warning( + f"Ignored match at ({center_x}, {center_y}) - not in typical Close Button regions (Top-Right or Bottom).") + is_valid_pos = False if max_val >= threshold: if is_valid_pos: # [Safety Check] 底部安全区排除 # 如果检测到的关闭按钮位于屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 区域内,认为是误判(如误触底部功能按钮) if center_y > (target_h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)): - logger.warning(f"Ignored Ad Close Button at ({center_x}, {center_y}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO*100)}%).") + logger.warning( + f"Ignored Ad Close Button at ({center_x}, {center_y}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}%).") # 也可以保存一下调试图 if debug_dir: os.makedirs(debug_dir, exist_ok=True) debug_img = target.copy() - cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 0, 128), 2) # Dark Red for Safety Ignored - cv2.putText(debug_img, f"SAFETY IGNORED", + cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 0, 128), + 2) # Dark Red for Safety Ignored + cv2.putText(debug_img, f"SAFETY IGNORED", (top_left[0], top_left[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 128), 1) save_image(os.path.join(debug_dir, "debug_ad_close_safety_ignored.jpg"), debug_img) return None logger.info(f"Found Ad Close Button at ({center_x}, {center_y})") - + if debug_dir: os.makedirs(debug_dir, exist_ok=True) debug_img = target.copy() cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 0, 255), 2) cv2.circle(debug_img, (center_x, center_y), 5, (0, 255, 0), -1) - cv2.putText(debug_img, f"Conf: {max_val:.2f}, Sc: {best_scale:.2f}", + cv2.putText(debug_img, f"Conf: {max_val:.2f}, Sc: {best_scale:.2f}", (top_left[0], top_left[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) save_image(os.path.join(debug_dir, "debug_ad_close_x.jpg"), debug_img) - + return (center_x, center_y) else: - # 虽然置信度高,但是位置不对,保存为 False Positive 供调试 - if debug_dir: + # 虽然置信度高,但是位置不对,保存为 False Positive 供调试 + if debug_dir: os.makedirs(debug_dir, exist_ok=True) debug_img = target.copy() - cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 165, 255), 2) # Orange for ignored - cv2.putText(debug_img, f"IGNORED Pos", + cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 165, 255), + 2) # Orange for ignored + cv2.putText(debug_img, f"IGNORED Pos", (top_left[0], top_left[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 165, 255), 1) save_image(os.path.join(debug_dir, "debug_ad_close_ignored.jpg"), debug_img) - + # 如果没找到,但有一定置信度,也保存一下调试图以便分析 if max_val > 0.4 and debug_dir: os.makedirs(debug_dir, exist_ok=True) debug_img = target.copy() top_left = max_loc cv2.rectangle(debug_img, top_left, (top_left[0] + best_w, top_left[1] + best_h), (0, 255, 255), 2) - cv2.putText(debug_img, f"Failed Conf: {max_val:.2f}", + cv2.putText(debug_img, f"Failed Conf: {max_val:.2f}", (top_left[0], top_left[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1) save_image(os.path.join(debug_dir, "debug_ad_close_fail.jpg"), debug_img) return None + def detect_any_ad_close(image_path, template_dir, debug_dir=None): """ 遍历模板目录下的所有 ad_close*.jpg 进行匹配 """ if not os.path.exists(template_dir): return None - + for filename in os.listdir(template_dir): if filename.startswith("ad_close") and filename.endswith(".jpg"): template_path = os.path.join(template_dir, filename) @@ -348,6 +361,7 @@ def detect_any_ad_close(image_path, template_dir, debug_dir=None): return pos return None + def detect_bottom_close_circle(image_path, debug_dir=None): """ 通过几何特征检测底部的圆形关闭按钮 (常见于插屏广告) @@ -359,79 +373,81 @@ def detect_bottom_close_circle(image_path, debug_dir=None): """ if not os.path.exists(image_path): return None - + img = read_image(image_path) if img is None: return None - + h, w = img.shape[:2] - + # 1. 提取感兴趣区域 (ROI): 屏幕底部 25% (缩小范围,避开列表内容区) roi_top = int(h * 0.75) roi_h = h - roi_top roi = img[roi_top:h, :] - + # 转灰度 gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) # 高斯模糊降噪 gray_blurred = cv2.GaussianBlur(gray, (9, 9), 2) - + # 2. 霍夫圆变换检测圆形 # dp=1.2 (累加器分辨率), minDist=w/5 (圆心最小距离), param1=100 (Canny高阈值), param2=30 (圆心累加阈值), minR=w*0.04, maxR=w*0.1 # 缩小最大半径范围,防止识别到过大的按钮(如扫码充电按钮) - circles = cv2.HoughCircles(gray_blurred, cv2.HOUGH_GRADIENT, dp=1.2, minDist=w/5, - param1=100, param2=30, minRadius=int(w*0.04), maxRadius=int(w*0.09)) - + circles = cv2.HoughCircles(gray_blurred, cv2.HOUGH_GRADIENT, dp=1.2, minDist=w / 5, + param1=100, param2=30, minRadius=int(w * 0.04), maxRadius=int(w * 0.09)) + if circles is not None: circles = np.round(circles[0, :]).astype("int") - + best_circle = None min_dist_to_center = float('inf') - + for (cx, cy, r) in circles: # 还原到全图坐标 global_cy = roi_top + cy global_cx = cx - + # 过滤1: 必须在水平中心附近 (容差 15%,收紧限制) - if abs(global_cx - w//2) > (w * 0.15): + if abs(global_cx - w // 2) > (w * 0.15): continue - + # 过滤2: 垂直方向限制 (必须在屏幕 80% - 95% 之间) # 这样可以避开位于 60%-80% 区域的列表项卡片 rel_y = global_cy / h if rel_y < 0.80 or rel_y > 0.95: continue - - dist = abs(global_cx - w//2) + + dist = abs(global_cx - w // 2) if dist < min_dist_to_center: min_dist_to_center = dist best_circle = (global_cx, global_cy, r) - + if best_circle: cx, cy, r = best_circle # 确保转换为标准的 Python int,否则 uiautomator2 click 可能会报错 (JSON serializable error) cx, cy, r = int(cx), int(cy), int(r) - + # [Safety Check] 底部安全区排除 # 如果检测到的圆形按钮位于屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 区域内,认为是误判(如误触底部扫码充电等) if cy > (h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)): - logger.warning(f"Ignored Bottom Circle at ({cx}, {cy}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO*100)}%).") + logger.warning( + f"Ignored Bottom Circle at ({cx}, {cy}) - in Bottom Safety Zone ({int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}%).") return None logger.info(f"Found Bottom Circle Button via Hough: ({cx}, {cy}), r={r}") - + if debug_dir: os.makedirs(debug_dir, exist_ok=True) debug_img = img.copy() cv2.circle(debug_img, (cx, cy), r, (0, 255, 0), 2) cv2.circle(debug_img, (cx, cy), 2, (0, 0, 255), 3) save_image(os.path.join(debug_dir, "debug_bottom_circle.jpg"), debug_img) - + return (cx, cy) - + return None + def find_expand_button_position(image_path, debug_dir=None, debug_filename_prefix=None): """ 通过几何特征识别"全部时段"按钮的位置 @@ -443,101 +459,101 @@ def find_expand_button_position(image_path, debug_dir=None, debug_filename_prefi """ if not os.path.exists(image_path): return None - + img = read_image(image_path) if img is None: return None - + h, w = img.shape[:2] - + # 转灰度 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) - + # 边缘检测 edges = cv2.Canny(gray, 50, 150) - + # 定义区域 left_w = int(w * 0.35) right_w = int(w * 0.65) - + # 提取各部分边缘 left_part = edges[:, :left_w] right_part = edges[:, right_w:] center_part = edges[:, left_w:right_w] - + # 计算每一行的边缘点数量 row_sum_left = np.sum(left_part, axis=1) / 255.0 row_sum_right = np.sum(right_part, axis=1) / 255.0 row_sum_center = np.sum(center_part, axis=1) / 255.0 - + # 筛选条件:左右边缘点很少,中间边缘点较多 # 阈值可以根据实际情况调整 # 允许少量噪点,所以不是严格的0,而是小于某个较小值(例如宽度的1%) - noise_threshold = 2 # 允许2个像素的噪点 - content_threshold = 5 # 中间至少有5个像素的边缘 - + noise_threshold = 2 # 允许2个像素的噪点 + content_threshold = 5 # 中间至少有5个像素的边缘 + candidates = (row_sum_left <= noise_threshold) & \ (row_sum_right <= noise_threshold) & \ (row_sum_center >= content_threshold) - + # 找到连续的候选行 y_indices = np.where(candidates)[0] - + if len(y_indices) == 0: return None - + # 将连续行分组 segments = [] if len(y_indices) > 0: start = y_indices[0] prev = y_indices[0] for y in y_indices[1:]: - if y > prev + 5: # 允许5像素断裂 + if y > prev + 5: # 允许5像素断裂 segments.append((start, prev)) start = y prev = y segments.append((start, prev)) - + # 筛选最合适的段 best_segment = None - + # 我们期望按钮在屏幕中下部,且高度适中(例如 20-100px) # 且通常是在价格表下方。假设价格表占据了屏幕上部。 # 我们可以简单地取符合条件的段中,Y值最大的那个(最靠下的),或者最符合"中间有字"特征的。 # 考虑到页面底部可能有其他干扰,取"中下部"的一个。 - + valid_segments = [] for start, end in segments: height = end - start mid_y = (start + end) // 2 - + # 过滤掉太高或太矮的区域 if height < 20 or height > 150: continue - + # 过滤掉顶部的区域(可能是标题栏误判) if mid_y < h * 0.3: continue - + # 过滤掉底部的区域(可能是底部按钮) if mid_y > h * 0.9: continue - + valid_segments.append((start, end)) - + if not valid_segments: return None - + # 如果有多个,通常"全部时段"是在价格表之后,紧接着的一个 # 这里我们取第一个(最靠上的)符合条件的段,因为它紧跟在价格表下方 # 或者取所有段中,中间内容最"紧凑"的? # 让我们简单点,取第一个符合条件的段。 best_segment = valid_segments[0] - + start, end = best_segment center_y = (start + end) // 2 center_x = w // 2 - + if debug_dir: os.makedirs(debug_dir, exist_ok=True) debug_img = img.copy() @@ -545,7 +561,7 @@ def find_expand_button_position(image_path, debug_dir=None, debug_filename_prefi cv2.rectangle(debug_img, (0, start), (w, end), (0, 255, 0), 2) # 画出红点 cv2.circle(debug_img, (center_x, center_y), 10, (0, 0, 255), -1) - + # 保存 flag 图片 if debug_filename_prefix: # Sanitize filename: remove invalid chars @@ -555,11 +571,11 @@ def find_expand_button_position(image_path, debug_dir=None, debug_filename_prefi else: timestamp = time.strftime("%Y%m%d_%H%M%S") debug_name = f"{timestamp}_flag_expand.jpg" - + debug_path = os.path.join(debug_dir, debug_name) save_image(debug_path, debug_img) logger.info(f"Saved debug image to {debug_path}") - + return (int(center_x), int(center_y)) @@ -568,22 +584,23 @@ def get_row_stats(gray): 计算每一行的统计特征 """ h, w = gray.shape - + # 中央区域 (用于检测内容) center_x = w // 2 strip_w = 100 - center_strip = gray[:, center_x - 50 : center_x + 50] - + center_strip = gray[:, center_x - 50: center_x + 50] + # 边缘区域 (用于检测背景/边距) # 假设边距至少有 10px edge_strip = gray[:, 0:20] - + row_means = np.mean(center_strip, axis=1) row_stds = np.std(center_strip, axis=1) edge_means = np.mean(edge_strip, axis=1) - + return row_means, row_stds, edge_means + def crop_cards_from_image(img_path, output_dir=None, save_debug=True): """ 从图片中裁剪场站卡片 @@ -604,53 +621,53 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) h, w = gray.shape - + row_means, row_stds, edge_means = get_row_stats(gray) - + # 参数定义 # 背景灰度值范围 (根据 Analyze2.py 的输出,背景约 242) BG_GRAY_MIN = 230 BG_GRAY_MAX = 250 - + # 间隙判定:中央区域也是均匀的灰度 # 真正的背景灰度约 242。卡片内的伪背景(不够白)约 246-248。 # 所以降低 GAP_MEAN_MAX 以区分 Gap 和 Dirty White。 GAP_MEAN_MIN = 235 - GAP_MEAN_MAX = 244 + GAP_MEAN_MAX = 244 GAP_STD_MAX = 10.0 - + segments = [] is_in_card = False start_y = 0 - + # 记录原始的行状态,用于后续可能的合并优化 row_is_card = [] - + for y in range(h): rm = row_means[y] rs = row_stds[y] em = edge_means[y] - + # 1. 判定当前行是否可能是卡片的一部分 # 条件A: 边缘是灰色的 (说明有边距,排除了全宽的Header/Footer) has_gray_margin = (BG_GRAY_MIN <= em <= BG_GRAY_MAX) - + # 条件B: 中央不是均匀的背景灰 (说明有内容,或者是白色底) # 如果是 Gap,则 Mean 在 Gap范围 且 Std 很小 is_gap = (GAP_MEAN_MIN <= rm <= GAP_MEAN_MAX) and (rs < GAP_STD_MAX) - + is_card_row = has_gray_margin and not is_gap row_is_card.append(is_card_row) - + # 简单的形态学闭运算:填补卡片内部的小裂缝 # 如果一个 False (Gap) 的上下都是 True (Card),且 Gap 长度很短,则将其视为 Card # 允许的裂缝最大长度 # [优化] 减小填补阈值,避免把两个卡片中间的真 Gap 填补了导致粘连 # 之前是 10,现在改为 3。 - MAX_GAP_FILL = 3 - + MAX_GAP_FILL = 3 + cleaned_row_is_card = row_is_card[:] - + # 查找连续的 False 区域 i = 0 while i < h: @@ -661,18 +678,18 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): i += 1 gap_end = i gap_len = gap_end - gap_start - + # Check context prev_is_card = (gap_start > 0) and cleaned_row_is_card[gap_start - 1] next_is_card = (gap_end < h) and cleaned_row_is_card[gap_end] - + if prev_is_card and next_is_card and gap_len <= MAX_GAP_FILL: # Fill the gap for k in range(gap_start, gap_end): cleaned_row_is_card[k] = True else: i += 1 - + # 根据 cleaned_row_is_card 生成 segments is_in_card = False start_y = 0 @@ -686,11 +703,11 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): is_in_card = False end_y = y height = end_y - start_y - + # 过滤太矮的区域 - if height > 100: # 稍微放宽一点,150 -> 100 + if height > 100: # 稍微放宽一点,150 -> 100 segments.append((start_y, end_y)) - + # 处理最后一个 segment if is_in_card: end_y = h @@ -700,7 +717,7 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): # [新增] 触底过滤:过滤掉延伸到图片底部的 Segment # 底部通常是导航栏或被截断的卡片 - BOTTOM_MARGIN = 50 # 距离底部多少像素内算触底 + BOTTOM_MARGIN = 50 # 距离底部多少像素内算触底 valid_segments_scan = [] for y1, y2 in segments: if y2 >= h - BOTTOM_MARGIN: @@ -710,7 +727,7 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): segments = valid_segments_scan logger.info(f" Found {len(segments)} potential segments based on vertical scan.") - + # 1.5 Group Segments by Large Gaps (to separate Header / List / Footer) # The filter bar usually creates a large gap (e.g. > 60px). SECTION_GAP_MIN = 60 @@ -718,19 +735,19 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): if segments: current_group = [segments[0]] for i in range(1, len(segments)): - prev_end = segments[i-1][1] + prev_end = segments[i - 1][1] curr_start = segments[i][0] gap = curr_start - prev_end - + if gap > SECTION_GAP_MIN: groups.append(current_group) current_group = [] - + current_group.append(segments[i]) groups.append(current_group) - + logger.info(f" Found {len(groups)} segment groups.") - + # Select the Target Group # Strategy: Select the LAST group that has at least one 'substantial' segment (H > 150). # This assumes the Station List is the main content and usually at the bottom (scrolling area). @@ -742,55 +759,55 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): if (y2 - y1) > 150: has_content = True break - + if has_content: target_group = g break - + if not target_group and segments: # Fallback to all segments if no substantial group found target_group = segments - + logger.info(f" Selected group with {len(target_group)} segments.") - + # 2. 确定每个卡片的左右边界 (Width Refinement) # 收集所有 segment 的建议边界,然后统一 - + candidate_x1 = [] candidate_x2 = [] - + temp_valid_segments = [] for y1, y2 in target_group: # 取中间一段行来分析 mid_y = (y1 + y2) // 2 sample_h = min(10, y2 - y1) - sample_rows = gray[mid_y - sample_h//2 : mid_y + sample_h//2, :] + sample_rows = gray[mid_y - sample_h // 2: mid_y + sample_h // 2, :] col_means = np.mean(sample_rows, axis=0) - + # 从左向右找第一个非灰像素 x1 = 0 for x in range(w // 2): if not (BG_GRAY_MIN <= col_means[x] <= BG_GRAY_MAX): x1 = x break - + # 从右向左找第一个非灰像素 x2 = w for x in range(w - 1, w // 2, -1): if not (BG_GRAY_MIN <= col_means[x] <= BG_GRAY_MAX): x2 = x break - + if x1 >= x2 or (x2 - x1) < w * 0.5: - # 如果这个 segment 无法确定宽度,可能不是有效卡片,暂不参与宽度投票 - # 但为了不漏掉,暂时先记录,用默认值填充 - logger.warning(f" Warning: Segment {y1}-{y2} has weird width {x2-x1}.") - pass + # 如果这个 segment 无法确定宽度,可能不是有效卡片,暂不参与宽度投票 + # 但为了不漏掉,暂时先记录,用默认值填充 + logger.warning(f" Warning: Segment {y1}-{y2} has weird width {x2 - x1}.") + pass else: candidate_x1.append(x1) candidate_x2.append(x2) - + temp_valid_segments.append((y1, y2)) # 计算统一宽度 @@ -804,13 +821,13 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): # 通常卡片是对齐的,所以 x1 应该几乎一样。 final_x1 = int(np.median(candidate_x1)) final_x2 = int(np.median(candidate_x2)) - + # 稍微加点 Padding (但不要超过图片边界) final_x1 = max(0, final_x1 - 5) final_x2 = min(w, final_x2 + 5) - + logger.info(f" Unified Width: X={final_x1}-{final_x2}, W={final_x2 - final_x1}") - + # 3. 过滤高度异常的卡片 (Height Filtering) # 计算所有潜在卡片的高度 final_cards = [] @@ -819,19 +836,20 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): else: heights = [y2 - y1 for y1, y2 in temp_valid_segments] if not heights: - logger.info(" No heights to calculate.") + logger.info(" No heights to calculate.") else: max_h = max(heights) median_h = np.median(heights) - + # 策略:如果高度差异较大,丢弃过小的卡片 # 阈值设定为 Median 的 75% (允许一定程度的偏差,如 220 vs 258 是允许的,但 150 vs 250 应被过滤) # 使用 Median 比 Max 更稳健,避免被单个超高卡片(如广告)带偏 threshold_h = median_h * 0.70 - + for (y1, y2), h in zip(temp_valid_segments, heights): if h < threshold_h: - logger.info(f" Filtering out segment Y={y1}-{y2} (H={h}) because it's too short (Threshold={threshold_h:.1f}).") + logger.info( + f" Filtering out segment Y={y1}-{y2} (H={h}) because it's too short (Threshold={threshold_h:.1f}).") else: final_cards.append((y1, y2, final_x1, final_x2)) logger.info(f" Card: Y={y1}-{y2}, X={final_x1}-{final_x2}, H={h}") @@ -839,15 +857,15 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): # 4. 保存结果 if output_dir is None: output_dir = os.path.dirname(img_path) - + base_name = os.path.basename(img_path) stem, ext = os.path.splitext(base_name) - + # 准备可视化标记图 debug_img = img.copy() - + results = [] - + # 准备 JSON 数据结构 json_data = { "image": base_name, @@ -855,10 +873,10 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): "height": h, "cards": [] } - + # 准备 _vl.jpg (只画框,不画红点) vl_img = img.copy() - + logger.info(f" Step [2.1/VL] 准备在 VL 图片上绘制 {len(final_cards)} 个场站的绿色方框...") for idx, (y1, y2, x1, x2) in enumerate(final_cards): @@ -868,7 +886,7 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): h_card = y2 - y1 click_x = int(x1 + w_card * 0.15) click_y = int(y1 + h_card * 0.20) - + # [修改] 不再保存单张子图,只记录元数据 # card = img[y1:y2, x1:x2] # 文件名添加坐标: _ClickX_ClickY @@ -876,17 +894,17 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): # out_path = os.path.join(output_dir, out_name) # cv2.imwrite(out_path, card) # logger.info(f" Saved {out_path}") - + # results.append((out_path, (click_x, click_y))) - + # 在标记图上画红点 (实心圆, 半径10, 红色BGR) cv2.circle(debug_img, (click_x, click_y), 10, (0, 0, 255), -1) # [修改] 必须画绿框,因为后续视觉模型依赖这个框来识别范围 cv2.rectangle(debug_img, (x1, y1), (x2, y2), (0, 255, 0), 2) - + # 在 _vl 图上只画绿框 cv2.rectangle(vl_img, (x1, y1), (x2, y2), (0, 255, 0), 2) - + # 收集 JSON 数据 card_info = { "id": idx + 1, @@ -894,29 +912,29 @@ def crop_cards_from_image(img_path, output_dir=None, save_debug=True): "click_point": [click_x, click_y] } json_data["cards"].append(card_info) - + # 记录区域信息供调用者使用 (如果需要) # 格式: (None, (click_x, click_y), (x1, y1, x2, y2)) results.append((None, (click_x, click_y), (x1, y1, x2, y2))) - + # [删除] 之前生成的单张 _for_vl.jpg 逻辑已移除 - + # 保存标记图 (_flag.jpg) if save_debug: flag_out_path = os.path.join(output_dir, f"{stem}_flag{ext}") save_image(flag_out_path, debug_img) logger.info(f" Saved Debug Image: {flag_out_path}") - + # 保存 _vl.jpg vl_out_path = os.path.join(output_dir, f"{stem}_vl{ext}") save_image(vl_out_path, vl_img) logger.info(f" Step [2.2/VL] 已保存带有绿色方框的图片: {vl_out_path}") - + # 保存 .json import json json_out_path = os.path.join(output_dir, f"{stem}.json") with open(json_out_path, 'w', encoding='utf-8') as f: json.dump(json_data, f, ensure_ascii=False, indent=4) logger.info(f" Step [2.3/JSON] 已保存场站坐标元数据: {json_out_path}") - + return results diff --git a/Apps/XinDianTu/Opener.py b/Apps/XinDianTu/Opener.py index 1656b28..814ccdd 100644 --- a/Apps/XinDianTu/Opener.py +++ b/Apps/XinDianTu/Opener.py @@ -1,14 +1,15 @@ # coding=utf-8 import asyncio import logging +import os import time import uuid -import os + import uiautomator2 as u2 -from Util.Kit import take_screenshot, detect_black_agree_button, click_image_template, detect_ad_close_x, detect_any_ad_close, detect_bottom_close_circle -from Util.ObsUtil import ObsUploader -from Util.XinDianTuReadImageKit import XinDianTuReadImageKit -from Config.Config import OBS_TMP_PREFIX, CDN_DOMAIN, TEMP_IMAGE_DIR + +from Apps.XinDianTu.Kit import take_screenshot, detect_black_agree_button, detect_any_ad_close, \ + detect_bottom_close_circle +from Config.Config import TEMP_IMAGE_DIR # pip install adbutils # 配置日志输出,方便调试和监控 diff --git a/Apps/XinDianTu/Run.py b/Apps/XinDianTu/Run.py index ecb1e89..acf63ae 100644 --- a/Apps/XinDianTu/Run.py +++ b/Apps/XinDianTu/Run.py @@ -12,8 +12,6 @@ if project_root not in sys.path: from Apps.XinDianTu.Service import XinDianTuService from Apps.XinDianTu import Opener from Apps.XinDianTu import Crawler -from Apps.XinDianTu import Coord as Y4_Coord -from Apps.XinDianTu import GetCookie as T2_GetAmapCookie # 配置日志 logging.basicConfig( @@ -59,35 +57,6 @@ async def main(): except Exception as e: logger.error(f"❌ 步骤 2 执行异常: {e}") return - - # 步骤 3: 补全坐标 (调用 Y4_Coord) - logger.info(">>> 步骤 3: 启动高德地图坐标回填任务...") - try: - # 首次尝试,开启 Cookie 检查 - await Y4_Coord.main(check_cookie_first=True) - except Y4_Coord.CookieInvalidError: - logger.warning("⚠️ 检测到高德地图 Cookie 失效或缺失。") - logger.info(">>> 正在启动 Cookie 获取工具 (T2)...") - logger.info("请在弹出的浏览器窗口中完成登录,然后在控制台按回车确认。") - - # 调用同步的 T2 工具 - # 由于 T2 包含 input() 阻塞,且这是客户端脚本,直接调用即可 - try: - # 传入 force=True 跳过“文件已存在”的询问,因为我们确定它无效 - # run_in_executor 用于避免完全阻塞事件循环(虽然在这里影响不大) - loop = asyncio.get_event_loop() - success = await loop.run_in_executor(None, T2_GetAmapCookie.run_login_tool, True) - - if success: - logger.info("✅ Cookie 更新成功,正在重新启动坐标回填任务...") - # 再次尝试执行,这次可以跳过检查(刚获取完)或者继续检查 - await Y4_Coord.main(check_cookie_first=False) - else: - logger.error("❌ Cookie 获取失败或用户取消,跳过坐标回填步骤。") - except Exception as e: - logger.error(f"❌ 调用 T2 工具失败: {e}") - except Exception as e: - logger.error(f"❌ 步骤 3 执行异常: {e}") finally: # 确保数据库连接关闭 diff --git a/Apps/XinDianTu/Service.py b/Apps/XinDianTu/Service.py index d5b0505..3cc1402 100644 --- a/Apps/XinDianTu/Service.py +++ b/Apps/XinDianTu/Service.py @@ -1,19 +1,17 @@ import hashlib +import logging import os +import re import sys import uuid -import logging -import json -import re -from datetime import datetime, timedelta +from datetime import datetime # Ensure sys path includes root for imports if not already project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) if project_root not in sys.path: sys.path.append(project_root) -from sqlalchemy import text -from Util.XinDianTuReadImageKit import XinDianTuReadImageKit +from Apps.XinDianTu.XinDianTuReadImageKit import XinDianTuReadImageKit from DbKit.Db import Db from Config.Config import DB_URL from Model.StationProfile import StationProfile diff --git a/Apps/XinDianTu/XinDianTuReadImageKit.py b/Apps/XinDianTu/XinDianTuReadImageKit.py index edbfcee..64bd3bc 100644 --- a/Apps/XinDianTu/XinDianTuReadImageKit.py +++ b/Apps/XinDianTu/XinDianTuReadImageKit.py @@ -9,13 +9,16 @@ import logging import base64 from openai import OpenAI, BadRequestError from Config.Config import ( - ALY_LLM_API_KEY, VL_MODEL_NAME, VL_MODEL_NAME_AD, + ALY_LLM_API_KEY, VL_MODEL_NAME, VL_MODEL_NAME_AD +) +from Apps.XinDianTu.Config.Setting import ( SAFE_EXCLUDE_RATIO, FALLBACK_WIDTH, FALLBACK_HEIGHT, BOTTOM_SAFE_EXCLUDE_RATIO ) logger = logging.getLogger(__name__) + class XinDianTuReadImageKit: _client = OpenAI( api_key=ALY_LLM_API_KEY, @@ -105,7 +108,7 @@ class XinDianTuReadImageKit: # 获取显示宽高,增加容错 display_width = float(device_info.get("displayWidth") or device_info.get("width") or 1080) display_height = float(device_info.get("displayHeight") or device_info.get("height") or 2400) - + bounds = obj.get("bounds") bn = obj.get("bounds_norm") text_bounds = obj.get("station_name_bounds") @@ -116,7 +119,7 @@ class XinDianTuReadImageKit: def get_pixel_coords(b_data, d_w, d_h): if not b_data: return None - + # Extract values based on dict or list if isinstance(b_data, list) and len(b_data) == 4: v1, v2, v3, v4 = b_data @@ -126,7 +129,7 @@ class XinDianTuReadImageKit: v2 = b_data.get("top") if b_data.get("top") is not None else b_data.get("y1") v3 = b_data.get("right") if b_data.get("right") is not None else b_data.get("x2") v4 = b_data.get("bottom") if b_data.get("bottom") is not None else b_data.get("y2") - + if any(v is None for v in (v1, v2, v3, v4)): return None else: @@ -138,9 +141,9 @@ class XinDianTuReadImageKit: return None max_v = max(v1, v2, v3, v4) - + # 1. 0-1 归一化坐标 - if max_v <= 1.05: + if max_v <= 1.05: x1, y1, x2, y2 = v1 * d_w, v2 * d_h, v3 * d_w, v4 * d_h # 2. 0-1000 归一化坐标 (Qwen-VL 常用) elif max_v <= 1005: @@ -148,20 +151,24 @@ class XinDianTuReadImageKit: # 3. 绝对像素坐标 else: x1, y1, x2, y2 = v1, v2, v3, v4 - + return min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2) # 优先使用场站名称区域进行点击 (最安全) - coords = get_pixel_coords(text_bn, display_width, display_height) or get_pixel_coords(text_bounds, display_width, display_height) + coords = get_pixel_coords(text_bn, display_width, display_height) or get_pixel_coords(text_bounds, + display_width, + display_height) if coords: x1, y1, x2, y2 = coords uia_x = int(x1 + (x2 - x1) / 2) uia_y = int(y1 + (y2 - y1) / 2) - logger.info(f"坐标计算: 使用文字区域 -> ({uia_x}, {uia_y}) | 区域: {coords} | 屏幕: {display_width}x{display_height}") - + logger.info( + f"坐标计算: 使用文字区域 -> ({uia_x}, {uia_y}) | 区域: {coords} | 屏幕: {display_width}x{display_height}") + # 备选:使用整个卡片区域 if uia_x is None: - coords = get_pixel_coords(bn, display_width, display_height) or get_pixel_coords(bounds, display_width, display_height) + coords = get_pixel_coords(bn, display_width, display_height) or get_pixel_coords(bounds, display_width, + display_height) if coords: x1, y1, x2, y2 = coords w, h = x2 - x1, y2 - y1 @@ -172,24 +179,27 @@ class XinDianTuReadImageKit: # 默认中心点,但稍微偏上一点,避开底部可能的按钮 uia_x = int(x1 + w / 2) uia_y = int(y1 + h * 0.4) - logger.info(f"坐标计算: 使用卡片区域 -> ({uia_x}, {uia_y}) | 区域: {coords} | 屏幕: {display_width}x{display_height}") + logger.info( + f"坐标计算: 使用卡片区域 -> ({uia_x}, {uia_y}) | 区域: {coords} | 屏幕: {display_width}x{display_height}") # --- 安全过滤:过滤掉屏幕顶部的点击坐标 (通常是广告或菜单) --- if uia_y is not None: # 如果点击点在屏幕顶部 SAFE_EXCLUDE_RATIO 范围内,极大概率是误触广告位,将其排除 if uia_y < (display_height * SAFE_EXCLUDE_RATIO): - logger.warning(f"安全排除: 坐标 ({uia_x}, {uia_y}) 位于屏幕顶部 {int(SAFE_EXCLUDE_RATIO*100)}% 区域,疑似广告或菜单,已忽略。") + logger.warning( + f"安全排除: 坐标 ({uia_x}, {uia_y}) 位于屏幕顶部 {int(SAFE_EXCLUDE_RATIO * 100)}% 区域,疑似广告或菜单,已忽略。") return None # 如果点击点在屏幕底部 BOTTOM_SAFE_EXCLUDE_RATIO 范围内,极大概率是误触底部功能区(如扫码充电),将其排除 if uia_y > (display_height * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)): - logger.warning(f"安全排除: 坐标 ({uia_x}, {uia_y}) 位于屏幕底部 {int(BOTTOM_SAFE_EXCLUDE_RATIO*100)}% 区域,疑似底部功能区,已忽略。") + logger.warning( + f"安全排除: 坐标 ({uia_x}, {uia_y}) 位于屏幕底部 {int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}% 区域,疑似底部功能区,已忽略。") return None if uia_x is not None and uia_y is not None: obj["uia_center_x"] = uia_x obj["uia_center_y"] = uia_y - + # Clean up temporary fields for k in ["bounds", "bounds_norm", "station_name_bounds", "station_name_bounds_norm"]: if k in obj: @@ -261,13 +271,13 @@ class XinDianTuReadImageKit: device_info = XinDianTuReadImageKit._FALLBACK_DEVICE_INFO prompt = ( - "仅输出JSON对象(不含任何说明文字)。" - "请在图片中找到场站名称完全匹配“" + str(station_name) + "”的那一行/卡片," - "返回该场站名称文字本身的区域坐标(优先返回归一化坐标)。" - "返回格式示例:" - "{\"station_name_bounds\": {\"x1\": 100, \"y1\": 200, \"x2\": 300, \"y2\": 240}, " - "\"station_name_bounds_norm\": {\"left\": 0.1, \"top\": 0.2, \"right\": 0.3, \"bottom\": 0.24}}" - "注意:bounds使用0-1000归一化坐标空间;如果找不到,返回空JSON {}。" + "仅输出JSON对象(不含任何说明文字)。" + "请在图片中找到场站名称完全匹配“" + str(station_name) + "”的那一行/卡片," + "返回该场站名称文字本身的区域坐标(优先返回归一化坐标)。" + "返回格式示例:" + "{\"station_name_bounds\": {\"x1\": 100, \"y1\": 200, \"x2\": 300, \"y2\": 240}, " + "\"station_name_bounds_norm\": {\"left\": 0.1, \"top\": 0.2, \"right\": 0.3, \"bottom\": 0.24}}" + "注意:bounds使用0-1000归一化坐标空间;如果找不到,返回空JSON {}。" ) loop = asyncio.get_event_loop() @@ -293,7 +303,9 @@ class XinDianTuReadImageKit: try: data = json.loads(raw) - if isinstance(data, dict) and (data.get("station_name_bounds") or data.get("station_name_bounds_norm") or data.get("bounds") or data.get("bounds_norm")): + if isinstance(data, dict) and ( + data.get("station_name_bounds") or data.get("station_name_bounds_norm") or data.get( + "bounds") or data.get("bounds_norm")): data = XinDianTuReadImageKit._add_click_point(data, device_info, anchor="station_text") return { "uia_center_x": data.get("uia_center_x"), @@ -339,7 +351,8 @@ class XinDianTuReadImageKit: data = json.loads(raw) if isinstance(data, list): # 过滤掉 None (即被 _add_click_point 排除掉的顶部项) - data = [XinDianTuReadImageKit._add_click_point(x, device_info, anchor="station_text") for x in data if x.get("b_use", 1) == 1] + data = [XinDianTuReadImageKit._add_click_point(x, device_info, anchor="station_text") for x in data if + x.get("b_use", 1) == 1] data = [x for x in data if x is not None] elif isinstance(data, dict): if data.get("b_use", 1) == 1: @@ -389,7 +402,8 @@ class XinDianTuReadImageKit: if tasks: results = await asyncio.gather(*tasks, return_exceptions=True) for i, r in zip(idxs, results): - if isinstance(r, dict) and r.get("uia_center_x") is not None and r.get("uia_center_y") is not None: + if isinstance(r, dict) and r.get("uia_center_x") is not None and r.get( + "uia_center_y") is not None: data[i]["uia_center_x"] = r.get("uia_center_x") data[i]["uia_center_y"] = r.get("uia_center_y") @@ -418,11 +432,11 @@ class XinDianTuReadImageKit: """ if not json_metadata or "cards" not in json_metadata: return [] - + cards_meta = json_metadata["cards"] # 按 id 或 Y 坐标排序,确保顺序一致 (Kit 生成时已经是 top-down) - cards_meta.sort(key=lambda x: x["rect"][1]) - + cards_meta.sort(key=lambda x: x["rect"][1]) + prompt = ( "图片中用绿色矩形框标记了若干个充电站卡片区域。\n" "请按从上到下的顺序,依次识别每个绿色框内的场站信息,并返回一个JSON数组。\n" @@ -440,7 +454,7 @@ class XinDianTuReadImageKit: "\n" "严格返回纯JSON格式。" ) - + loop = asyncio.get_event_loop() resp = await loop.run_in_executor( None, @@ -457,10 +471,10 @@ class XinDianTuReadImageKit: ], ) ) - + content = resp.choices[0].message.content or "" raw = XinDianTuReadImageKit._extract_json(content) - + final_stations = [] try: llm_data = json.loads(raw) @@ -470,46 +484,48 @@ class XinDianTuReadImageKit: for i in range(count): item = llm_data[i] meta = cards_meta[i] - + if not item or not isinstance(item, dict): continue - + name = item.get("station_name") if not name: continue - + # 注入元数据中的点击坐标 click_pt = meta.get("click_point", [0, 0]) cx, cy = click_pt - + # 安全检查 img_h = 2400 if device_info: img_h = float(device_info.get("displayHeight") or device_info.get("height") or 2400) else: img_h = json_metadata.get("height", 2400) - + # 顶部安全排除 if cy < (img_h * SAFE_EXCLUDE_RATIO): - logger.warning(f"VL安全排除: 坐标 ({cx}, {cy}) 位于屏幕顶部 {int(SAFE_EXCLUDE_RATIO*100)}% 区域,已忽略。") + logger.warning( + f"VL安全排除: 坐标 ({cx}, {cy}) 位于屏幕顶部 {int(SAFE_EXCLUDE_RATIO * 100)}% 区域,已忽略。") continue - + # 底部安全排除 if cy > (img_h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO)): - logger.warning(f"VL安全排除: 坐标 ({cx}, {cy}) 位于屏幕底部 {int(BOTTOM_SAFE_EXCLUDE_RATIO*100)}% 区域,疑似底部功能区,已忽略。") + logger.warning( + f"VL安全排除: 坐标 ({cx}, {cy}) 位于屏幕底部 {int(BOTTOM_SAFE_EXCLUDE_RATIO * 100)}% 区域,疑似底部功能区,已忽略。") continue item["uia_center_x"] = cx item["uia_center_y"] = cy item["rect"] = meta.get("rect") - + final_stations.append(item) else: logger.warning(f"LLM return format error: expected list, got {type(llm_data)}") - + except Exception as e: logger.error(f"Error parsing VL response: {e}") - + return final_stations @staticmethod @@ -530,7 +546,7 @@ class XinDianTuReadImageKit: remote_path = f"tmp/patches/{os.path.basename(patch_path)}" uploader.upload_file(patch_path, remote_path) patch_url = f"{cdn_domain}/{remote_path}" - + # 记录上传后的 URL 供识别使用 seg["patch_url"] = patch_url tasks.append(XinDianTuReadImageKit.recognize_card_text(patch_url)) @@ -547,7 +563,7 @@ class XinDianTuReadImageKit: res["x"] = seg["center_x"] res["y"] = seg["center_y"] final_stations.append(res) - + # 4. 后置过滤 (复用原有过滤逻辑) if final_stations: processed_excluded_titles = {str(x).replace(" ", "").strip() for x in STATION_EXCLUDED_TITLES} @@ -580,40 +596,40 @@ class XinDianTuReadImageKit: img_data = np.array(img) logger.info(f"页面分析 - 分辨率: {width}x{height}") - + # 1. 行特征分析:识别分割线 (Divider) # 分割线特征:横贯全屏,亮度均匀,通常比纯白(255)稍暗,比文字内容亮 # 典型值:Mean=242-247, Std<10 - - row_types = [] # 0: Unknown/Content, 1: Divider, 2: White/Empty - + + row_types = [] # 0: Unknown/Content, 1: Divider, 2: White/Empty + # 采样点:左中右 l_x, m_x, r_x = int(width * 0.05), int(width * 0.5), int(width * 0.95) - + debug_rows = [] for y in range(height): # 避开顶部和底部导航栏 (15% - 85%) - 扩大排除范围以避免误识别 Header/Footer if y < height * 0.15 or y > height * 0.85: - row_types.append(1) # 视为无关区域 (标记为 Divider 以防止形成 Segment) + row_types.append(1) # 视为无关区域 (标记为 Divider 以防止形成 Segment) continue row = img_data[y] row_mean = np.mean(row) row_std = np.std(row) - + # 判定逻辑: # 1. 纯白行 (卡片底色) -> mean > 252 (允许微小噪点) # 2. 分割线 (Divider) -> 230 < mean < 252 且 std < 15 (颜色均匀) # 3. 内容行 (Content) -> mean <= 230 或 (mean > 230 且 std >= 15) (有文字/图片导致方差大) - + if row_mean > 252: - r_type = 2 # White/Empty + r_type = 2 # White/Empty elif 230 < row_mean <= 252 and row_std < 15: - r_type = 1 # Divider + r_type = 1 # Divider else: - r_type = 0 # Content - + r_type = 0 # Content + row_types.append(r_type) # Debug log sampling @@ -626,33 +642,33 @@ class XinDianTuReadImageKit: # 2. 聚合连续的 Content 区域 (Type 0 or Type 2 sandwiched by Type 0) # 实际上,卡片是由 Divider 分隔开的区域。 # 我们寻找两个 Divider 之间的区域,且该区域必须包含 Content (Type 0)。 - + segments = [] - + # 简化状态机: # 寻找非 Divider 的连续段 in_segment = False seg_start = -1 has_content = False - + raw_blocks = [] - + for y, r_type in enumerate(row_types): - if r_type != 1: # Not Divider + if r_type != 1: # Not Divider if not in_segment: in_segment = True seg_start = y has_content = False - + if r_type == 0: has_content = True - else: # Is Divider + else: # Is Divider if in_segment: # 结束一段 - if has_content: # 只有包含内容的段才算 + if has_content: # 只有包含内容的段才算 raw_blocks.append((seg_start, y)) in_segment = False - + # 处理最后一段 if in_segment and has_content: raw_blocks.append((seg_start, len(row_types))) @@ -660,52 +676,52 @@ class XinDianTuReadImageKit: # 3. 过滤和后处理 # 合并距离很近的块?或者过滤太小的块 base_name = os.path.splitext(os.path.basename(image_path))[0] - + valid_segments = [] logger.info(f"Initial raw blocks count: {len(raw_blocks)}") - + for i, (y1, y2) in enumerate(raw_blocks): h = y2 - y1 # 过滤太小的块 (可能是杂噪或单纯的文字行) - if h < 50: + if h < 50: # logger.debug(f"Block {i} too small: {h}") continue # 过滤太大的块 (可能是全屏错误) if h > 1000: continue - + # 再次确认内部是否有足够的“暗像素”(内容) # 避免切出纯白的空隙 region = img_data[y1:y2] region_mean = np.mean(region) - if region_mean > 254.5: # 整体太白 (放宽阈值,避免误杀大面积白色的卡片) + if region_mean > 254.5: # 整体太白 (放宽阈值,避免误杀大面积白色的卡片) logger.info(f" [-] 忽略区域 {i}: Y({y1}-{y2}), H={h}, 整体太白 (Mean={region_mean:.1f} > 254.5)") continue # --- 优化:边缘背景检查与宽度裁剪 (Margin Check & Crop) --- # 不再直接拒绝白边,而是尝试计算内容的有效宽度 # 假设:有效内容行的方差较高,或者亮度显著不同于背景 - + # 简单策略:保留全宽,但增加高度限制 - if h < 60: # 稍微降低阈值以测试 + if h < 60: # 稍微降低阈值以测试 logger.info(f" [-] 忽略区域 {i}: Y({y1}-{y2}), H={h}, 高度不足 (<60)") continue - + # 计算左右边距的平均亮度,辅助判断(仅记录日志,不强行过滤) margin_w = max(5, int(width * 0.05)) l_margin = img_data[y1:y2, 0:margin_w] - r_margin = img_data[y1:y2, width-margin_w:width] + r_margin = img_data[y1:y2, width - margin_w:width] l_mean = np.mean(l_margin) r_mean = np.mean(r_margin) - + # 如果是全宽卡片,左右边缘可能是白色的。 # 之前的逻辑: if l_mean > 252 or r_mean > 252: continue (导致漏检) # 现在移除该逻辑。 - + # --- 新增:自动裁剪水平宽度 (Auto Horizontal Crop) --- # 尝试找到内容的左右边界 (基于列的方差或亮度差异) x1, x2 = 0, width - + # 从左向右扫描 for x in range(0, int(width * 0.4), 2): col = img_data[y1:y2, x] @@ -713,22 +729,22 @@ class XinDianTuReadImageKit: if np.std(col) > 5 or np.mean(col) < 245: x1 = x break - + # 从右向左扫描 for x in range(width - 1, int(width * 0.6), -2): col = img_data[y1:y2, x] if np.std(col) > 5 or np.mean(col) < 245: x2 = x + 1 break - + # 增加一点 padding x1 = max(0, x1 - 10) x2 = min(width, x2 + 10) - + # 如果裁剪后宽度太小,可能不是有效卡片 if (x2 - x1) < width * 0.5: - logger.info(f" [-] 忽略区域 {i}: 裁剪后宽度过小 ({x2-x1})") - continue + logger.info(f" [-] 忽略区域 {i}: 裁剪后宽度过小 ({x2 - x1})") + continue # 保存 patch = img.crop((x1, y1, x2, y2)) @@ -743,8 +759,9 @@ class XinDianTuReadImageKit: "center_y": center_y, "y_range": (y1, y2) }) - logger.info(f" [+] 发现卡片 {i}: Y({y1}-{y2}), H={h}, Crop X({x1}-{x2}), 边缘(L={l_mean:.1f}, R={r_mean:.1f}), 已保存") - + logger.info( + f" [+] 发现卡片 {i}: Y({y1}-{y2}), H={h}, Crop X({x1}-{x2}), 边缘(L={l_mean:.1f}, R={r_mean:.1f}), 已保存") + logger.info(f"分析完成:识别到 {len(valid_segments)} 个区域") return valid_segments @@ -767,7 +784,7 @@ class XinDianTuReadImageKit: "role": "user", "content": [ { - "type": "text", + "type": "text", "text": "请识别图片中的充电站信息,并以 JSON 格式输出:{\"station_name\": \"...\", \"price\": \"...\", \"piles\": \"空闲数/总数\"}。只输出 JSON,不要有其他文字。" }, {"type": "image_url", "image_url": {"url": patch_url}} @@ -787,7 +804,7 @@ class XinDianTuReadImageKit: return json.loads(json_match.group()) except Exception: logger.warning(f"OCR 结果解析 JSON 失败: {content}") - + return {"station_name": "未知"} @staticmethod @@ -940,8 +957,8 @@ class XinDianTuReadImageKit: # 尝试捕获 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', '') - + 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}") @@ -956,7 +973,7 @@ class XinDianTuReadImageKit: 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: @@ -966,8 +983,8 @@ class XinDianTuReadImageKit: logger.error(f"API BadRequestError: {e}") return [] except Exception as e: - logger.error(f"API Unexpected Error: {e}") - return [] + logger.error(f"API Unexpected Error: {e}") + return [] content = resp.choices[0].message.content or "" raw = XinDianTuReadImageKit._extract_json(content) @@ -1147,7 +1164,7 @@ class XinDianTuReadImageKit: content = resp.choices[0].message.content or "" raw = XinDianTuReadImageKit._extract_json(content) - + result = {} try: data = json.loads(raw) @@ -1155,7 +1172,7 @@ class XinDianTuReadImageKit: result = data except Exception as e: logger.error(f"Error parsing address JSON: {e}") - + # 等待并合并按钮坐标结果 try: button_result = await button_task @@ -1284,7 +1301,7 @@ async def test3(station_name: str): if __name__ == "__main__": - #asyncio.run(test1()) + # asyncio.run(test1()) station_name = '吉林省看守所充电站' - #asyncio.run(test2(station_name)) + # asyncio.run(test2(station_name)) asyncio.run(test3(station_name)) diff --git a/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Crawler.cpython-310.pyc index f042670dbe9c1c08ee927e4c86bc673a06e9563a..4b4cf41696a1f1c99c2d7e72bb58406d55d1c121 100644 GIT binary patch delta 5120 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 delta 5106 zcmai2YfxLq6~24#m5_uG@2AAm2qS~R0t4bDfv>LZfcHiu z*s;^r3C`x_Iy6$#&a|279p3>`mz+u=gK+YMhy7u=K7iwa{%z?qW` z@M~1i{9gDqVY`Cj&5As~FM;L|&blQb0Y<#okwB9zoXSq1cq`*gpwwK>*G6)`Hj=k< z!cOdh8K2UL%NN9T zq?WB%6(QcG_|^mlVqC!Jq#z4Q_wv7xz`jaLya!jXxry}LfpxT8iiF8tB&=k^d>^i? zP1Cn}ik=pqMOK;=%c)iRB4fq!lokp%Z9b4(2xZYS(^#c%sA~#G z#~0Owsfn;QrY_DaK@CO*)xedoqSC7-r^yNKAEI|n->azt>W4_JgonCotEmMlX^LtA zRXaih=F$4nt*N>#inDW0%i@v}@duWcPS9VQ4|diwmjAuN7G!Ar;ZRgN#kluCv*M&5 zWfaq3M#aI0C3r-RFKGurT4*ui4@G7d{}Zj#S)(K{-x|=l~ z)-HA zddKn{dX~DZrAVXg*3rJqd3!vE#kTa$p;<_DS%Xv**M8384FT$lN1okf~0;3k_vIjHWw}> z8GMBW+OiF6YK;|3Jp!z;V!4N9q*I2BT!Zq7SiC~?lPe&ZeJ9{IE=0;?K`UDR0tqp_ z2olo4mAFXJL;2{`i=HWpm@t1{L01HBR5?>1cIt+f-dWovR-rq1Uhj|Y798Q+Fjcm(5p54$f3KL znv1<0L>(zjo1x{C61*GNW6v$=r7XQnbH&R^SpwzJ&RG#1IBra+XRWseA z4a9j_SQphKsl?7p$e`w{VOwatE|v;YqF$v}Yc<4)8%l*Wq}TAZdr1v|Y}()+&~~v| z`dMaSb~C7V6F2Dl@jANr4wdas_5F!>$nFK^g!^oaRFd7qb6q5Lr2e`%3p(^YT;@Kq z3yZ}9$n|pREyN9UD}BT6j_u!?Ya@F}Ln;F8hLcDmp#G3F#!a{b+?%F-a{|PnX-?3a zNz;%pU8C6W?gQc(umI^5f9KiVp7+g*bGCn-?rd4m_ zju|GgHr!FlJadS&;eJLL<+NhEiQ@I7ErCX%C`OF*hvLwR*2U$YBVjB5o%rzo%YRZ1 zc7r4Y*>dq6P+W*d(q50@Q6toB3M#BTrQ+`?6*-zb@5GN8D)13}^p?mf5w`tDm)YZ~ zQgWpNc?=S)e2rCsm!%p6seyTejck|!l3O6<>r%9Wqk4j4lp;giIb&N(kJC>b)iKBb zy_0kTckjA%u^FErovdK=uA2gsUsu{w>ykRo*Pmqb_{o%cu!3|F6I6txo@MKa55PPH zGP^l-3aB2^4WR~(s7iVaO4T_skQD=A<2!_z;vUk;023M9>thr?qPKHlkTd!LPMrp- zms38ToclS=^F{B&U3x$1TbI?$l@SbC7T%*DBnRUSc!Kmn@(z#z0DE6+Bm?oiI0h#! zROuj|Bz^Iwgm78VhsY2x{EQ4nBr=H4I0bzePmy81T~BMx0Ql^N2~-Sg%?l$QU`65MkRsuAksC zqA3|qJS>A82dDfIYg#*(F~+tO{p1bd26D`b_!7Q+&7waEFgxgcUS;eG^MY}kgUCOo z`PNO*ywrhp4E9`;3cYwIs0RPD9b~15D}Q>&x@G+ozeb$GE2(RQVaB*tJl{p~NG|Eb zSHbktd?iW29%ry)+qSP{a7FxBh6I$;CqFs8ZdPl_X|TLo@8Kf!hho6*C4>cRM-1>Y z+cAKHVgh0y;@Fx9$Hnvdqya%cqs6-8J)|>!0M{oF+_ifXf<8s2z;UZ&68s|R)7-M@ z)CK7*SN|-V!_V>jS>xQ-$XVdFFdwBtH=PQdpNGy*&hpUB=qJbw`1Dul@WhCp$0tB0 zU>6B+0jx-`lR!Ew5eq0S#LsgT{MyYy+~6Q?rkr_ZD+dg3OyU<71W@!P5$5~wH}E&{ zx7g0jvJ;25J+mG&UDU_~IGF9g621j{?gZbiUo;Z(27*_Nm+(5|%sG}b;O8*Ku0n;$lIJYar$LOLm-V)%e*|iF+P3OH+!WZ>5e1Y#i=+BB^c2>5mU#w}9{%+NL%s2FqfgH**p@`Bu3*m)qnZD1yL5b%lSogXIl7lrhtfZKML3uNJY z6IRK5)dtCAdBtXkd{XdyXLG)&1=&_CP0gbh0&_}q^UdN1l5-H=8q_1uB&|#8qJK#Z z1r>FO4IZWUoK@)9=5L*UM&{#e1X3Z~#BJ_j$DioxDsP`gF^F62NqUQk0qd)REQ8xXh=jEmxCc@5FN3VpI6g8L8 z=DC1&UQs=>NrVl+<&4ioU)bX@pJt?wzQ1P(bd_pnYq*K}=(`QKP$NCiSXn#3 zgvgP=1tn=!6&#BC7nEhSgN>qP9%FTkt~UM^h3KWdZ`biCoo8bC3v$k!qWRNruk59F z8m6K-w71FYbATUekVO)a9oZm=IU;xkzT&FuOL^5{aWNl#Ws@z)Cc2PavLlyR51jPf zrgK#lnW9vHav<`I$sv`A@@kG)#e40NS)`rKBSp1HMlj5@08@eF5ceaPdaJpiTq?-2 z!E#wDkZed4Wf^GM0ce;=dcV03SvMQ@eII4FHq9^1t(d&VqlNz7+mv~b*$06Y)K*&3 zvaoNJkofv^HTD| z>d!Z;THixQx5dif{bnu3d$BHDkEXhaSNw18!^rtNWrTy&}Z zyWXsaFso}!jwKjgj4FP7j)P5Fm8XNs68rbTV^OQQiPaIX|YWe>;M?#uvVqN-)uMfRIi#vOr zDo4rwh)}ax!}FZwwwgy@=^XSh=OisrMLVioR>J(R3-v=b{TtT&jx`L9+Vpalt9FJ_ zJPI84r>x6!cDt#i{ayY2jYzi2CiuYyV-v-%%nxiCc~Tx?-yG3iF2vZ^QeY{wW!f4= Q7_~z_<)8|lYw|z-4ZFfyhX4Qo diff --git a/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Kit.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ea864fe404cde4a94428aba65dca462022168890 GIT binary patch literal 17525 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 QgZ23LsOOsjHPZC|08l!~ivR!s literal 0 HcmV?d00001 diff --git a/Apps/XinDianTu/__pycache__/Opener.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Opener.cpython-310.pyc index 66a029949a260626145eb569ffd1cf2db230e835..b10d6d27b4b23b8c928c74dac6031414f1b86a9b 100644 GIT binary patch delta 1184 zcmah|O=ufO6rMM`+Fh;oM=L3|q8J+gU=WE@8VAzUslnjTgxVq0c9SR$V&hqyb!^Fu zRyY~jPq~B;a>|SpJHk15hyCWgdGCGi zd$a7n126lWvTd74JU@?;@WqKurw6}5o2M_Y0V0Sfi`J4|umJ_I;E2~HDv51i={J%@ zYeK;zIx)UOg%pVsQ|xJyAePuO!7*ar!?ae52dL2%WFDX<0J@F{Od%Bo-K#}Fas^%^ zOB@-yxgR^ex(Z*SLJm;P72y-a4RTxP5pEO6v_kGyP7<}P6idQDbzPeN#zw!+~iI-u3Z{p93iMkqD3^B}9f;W_UEJ6(+N`xD@i5O%N zQMbgfj}+OpeOlKdo5e)Fwt?$9(`kw6v$$^5hq8C!@LEG{t4pDNvJ=6g2n5SzB3 zH(VBL0$q%>Mp9bhx)W04)`JOYGknL#; z`uM2Z1-JQ(8^Q(t#QpyA1sQ8oO6#0}JnZO|>fBsVskE!wr6RJUF8N{UcVSiJpN!Wn zP{9EQ(-Hn-^6Rl4IwZ67#q#`oSemD4;ZawDC!kH_Jd;M a;!>GZ7lTm{$rU-C0S60bv4#0yYTzA{f*5lE delta 1456 zcma)6O>7%Q6rNeHch~FP^?w@D(9~&EVnIS%Do9Zk3JEAtnjh5(Z5KvFpm7mEW|Bm`&R!pxddi%aT}zj-t7dvCsZGqZn8 z{y1d>x~>BDTluc;9*jRR#)WbCqvtpoC}m88Z{xqL=k^W-hjqUfwU5 z1;1z(A#mV!iTy{AgyhEpA9qh7r3cMXltSv~z*%9!g{geZ` zuR5n6fF6Y4CP1)i0}(i9?%#!gH)pgvfwJ6rn>)Gh?EQxjm}Lm|Gt4`Qa!&aW90`L2 zoMo0jDRb_?+Jk#>WF`MA-|bpOq8H%Ibg}Na^&QLg?S^ATj=$^Kk<-s1CvxhMWh1NZ z1)*czqcwPWA-#Sl+}ia58#%ZF`=wiMbJ4Y%o2?s;jh1?C4N z`rmxg^L$4M&WpH?3}G?&ZX|WH++t^|RRH1V;!%zyiOS^5 zOn#cA@iUShl9zc2dD#=?eb3}|hV|vyqI^lAtq~3XO|+pUb1$)E{=_B!{9i@D96Ol8 zNnu|?Tp9{2RzamiuZWM-eP#B|_@0EvKZs)jEYM%1>r!9b4%{ZaD3@g{vAc<7YRTuI zK_AG~sq<{&uXh&Tt)cK{fOl%Qy=Vgm+w69-nFVa`c@Cz($uq?gBZeO{5DrvPNocSr z7<5Lt4yTXzmA4Y`b^1;Ep_JwMbI0##7)p6=7wA)cLYm;_8Tzxnb!wWK6&;sZXDX3> z$Ets3NBA;bGFH=E436P7+BHUCjXpJ8I8V!&FP7%HZ;+B(R~WE2B8RPd-3h}%)F703 z`l4^U&2O0%@{f;jpg|ED&=Bx7`g>-ja}HnPEZGYh4Y%3AMK;1C97Y+KTFbRtQQ+HA zfG^<-d`j7hTyK`WKP+%sf`iB*$wA^EGcdK7#~(7|V}(ui$Kt8s$2|4lYsM+g&=-Q{ zr*30*&|xkar|H+($!xq~UNRi-{~G-%dw#RP$8U4E%AlWe?$w>$$PJo9Rd`Vf<*FZ` ZmgmevMh@o~DDXu)3pLiVkgnvW{sE0?WZM7$ diff --git a/Apps/XinDianTu/__pycache__/Run.cpython-310.pyc b/Apps/XinDianTu/__pycache__/Run.cpython-310.pyc index c5e0116af530987d0f72d3674d26a28d9b7121fd..da0a0e55693ac569c0906e42adc0f38ac481123f 100644 GIT binary patch delta 663 zcmYjN&ubGw6rT5X-0W_W-TcxdTHA}HgciLhs8oxPyWkIE<6c5$ENN;Ib`xTP&{m6{ zJd{B^h?*WfiU)6khzIXQA!mjD0Scnn_U$H0XLuj;-uKO$@6C_ggQVSx$1O(d<|R>o zXKvY3klLEtSd*~gP$a|=p+7-j5{5|;*@j9=7{c6Sm9)$VtAXC*fhpPWs7ya(+W@T0 z1YBpF$z%OhV8NEDVOB%~Zs{MF>puck$pafW#2yzhnSah+;P`%^s$}!G^Fbpz(y0f% z@MsO+Rti9n}zq37xb{rj{3l-(fdY~9PhEB{dS+vAd)NHY^)VE z&mc3Sh~KT%r0~m?EwABL>!oU^>RxHLefL(qS*ll?H@fb%Zu4}z z?>QvS1_Rw8z;z2Ou7eGT79!Zl!2%9EYg3JXx3vVsfMfcInniELkODaQL(umuZlE!bkfK_GhL{e<*yn!^ E01&K+yZ`_I delta 1682 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 QEX7hm`rr9wNYfkq54yXb)&Kwi delta 3124 zcma)8OK=oL7~bCZYrV;{G{(UT zYO^%X4uo>~1e#5bTW}4#tm3`ssFuyW&?Iw0kM}imgZq1@CTBF~f=w^aTr%BL)Lzhw zF4r8Tg)62`**Bp+l4rk$?C$OO2FCc%0{5wsUe+m}rD|xs%ng6s+XREo4J%|Brn7pv=`%o9K2B3zC!b8ZbLI1bqY&5&9d96eWacJkQ&KkppO=9%Q9R!Pn%e4 zx|V!<{YO%sjIDM`M(EjL3=w`Gg(UnU9CR1pDZpbK?vpV}(0GD_7d$pW_n|cj z*bg|s?q+h;gAk7ao&h|^K~B&*8lbI3_jxoiQ+gU8zKccGnV?s0^$E!O1k5kbQbVgF zfu6ybvw^@|pcZ{9;) zxa@9Y$E*gm4iL+mgH!@+K@zpl;j;s2o`V!7qlVh7hEY>LuuVRP@>?Udjn`;Skt>yW1f;GV>cFDuB2Fw3v zt(`_#S8Mk(4X|3L=%E>+7cuM-2QRD{MNSoayLF^0coCEc*6Mj#!GIyaLvSPFB0_?M zTi->ZB-D6XfHM&o5Tr;InzvPdPc0QpDwbbQ*r~R5@;6&(`-Kdzo@oD#L@z4{*a@v78$<8{_4){0Ea6Q`UYo4;rfv5mz-%B9%q9+ zyQ+(@NsM%zLd~4)qStv_ZWGbmTvciT-KxHbO7b!sD}Y*wQPF$_utkZfiW)<~Exo~k zf4T|sJAh4vMWoF}@@`dKltxkX`N43-aOYKbxuR2pds3nmzhT1dWoROE{3LW0cCokh za2bk3EyK60OWek5rg{;%`QY{Bt<^7kM}kCVKkn?=Atvb`zVIqJ#d%#@G<9{svCBM@ zJNu8MHdf0^h0#ATB_husg3XQ zgI&z2E;CuzK#tsF2L@(H8~bEnsM-n_f>c3~m<5~;2Pi!z}tX#0UrQr zUl39YbP|Ug|2wz=#RG8?k7Yw~{$xVg#kSLVwPAO@v`3i9y>tgR@`ASGg`8!FcAynA z?NW)~TfDFcpvW>@GiZpJgS+!6hhEZo)hw7i(AF{ybA{4z&Na+YHcpy%`e$f-YIfRi zN2g{f?wpEr)Gp5%rBVMyW9;5wM=p+@?I&>696jUTKk-_0XoB_0T_nk#mV49>xs{); sSlU)=_TmTleU(%GB(|c1IwBTM_~ONrA$_4FNt1Mt|2Lit*JXqM0(+*8^Z)<= diff --git a/Apps/XinDianTu/__pycache__/XinDianTuReadImageKit.cpython-310.pyc b/Apps/XinDianTu/__pycache__/XinDianTuReadImageKit.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75d15a279e54c5c950e397bbac49dd80963720ed GIT binary patch literal 35029 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 literal 0 HcmV?d00001