This commit is contained in:
HuangHai
2026-01-14 13:14:05 +08:00
parent 11e1091c6a
commit 9e610b1906
6 changed files with 300 additions and 120 deletions

View File

@@ -11,7 +11,7 @@ if project_root not in sys.path:
from Util.VLMKit import VLMKit
from Apps.TeLaiDian.Kit import draw_rectangles, detect_cards_cv, setup_logger, read_image, detect_wide_rounded_card_cv
from Apps.TeLaiDian.Config.Setting import SAFE_EXCLUDE_RATIO, BOTTOM_SAFE_EXCLUDE_RATIO
from Apps.TeLaiDian.Config.Setting import SAFE_EXCLUDE_RATIO, BOTTOM_SAFE_EXCLUDE_RATIO, MIN_CARD_HEIGHT
# 初始化日志
logger = setup_logger("ReadImageKit")
@@ -242,36 +242,30 @@ class ReadImageKit:
"""
分析场站列表页图片,提取场站位置和基本信息
"""
cv_bboxes = detect_cards_cv(image_path, top_ratio=SAFE_EXCLUDE_RATIO, bottom_ratio=BOTTOM_SAFE_EXCLUDE_RATIO)
prompt = """
分析这张充电站列表截图,提取所有真实的充电站卡片。
if cv_bboxes:
img = read_image(image_path)
h, w = img.shape[:2] if img is not None else (2400, 1080)
y_threshold = h * 0.58
# 在最终绘制阶段再生成可视化,避免早期绘制产生误导
prompt = f"""
图片下半部分可能包含多个充电站卡片,请按从上到下识别这些卡片的名称与地址。
输出格式为 JSON 数组。
每个对象包含:
- "name": 场站名称
- "address": 场站地址
- "is_valid": true/false (是否为真实的场站卡片)
"""
else:
prompt = """
分析这张充电站列表截图,提取所有充电站卡片信息。
**重要限制**:
1. 忽略页面上半部分(坐标 y < 500)的所有内容,包括顶部的搜索框、广告 Banner 和“PLUS会员/我的卡券”等图标。
2. 仅提取下方重复出现的、包含“充电站”名称的矩形卡片。
输出格式为 JSON 数组,每个对象包含:
- "name": 场站名称
- "address": 场站地址
- "point": 场站卡片的中心点击坐标 [x, y]
- "bbox": 场站卡片的边界框 [x1, y1, x2, y2]
"""
要求:
1. 忽略页面上半部分(如顶部导航栏、搜索框、广告 Banner、筛选标签等)。
2. 仅识别下半部分一条条“充电站卡片”,每张卡片通常包含:场站名称、评分、最近充电时间、距离、价格、快/慢空闲数量等。
3. 不要把同一张卡片拆成多块;每条场站只对应一个矩形框。
对于每张卡片,请输出:
- name: 场站名称
- address: 场站地址(如果无法确定可置为 null)
- point: 卡片中心点击坐标 [x, y],使用归一化坐标 [0-1000](0 表示最左/最上,1000 表示最右/最下)
- bbox: 卡片外接矩形边界 [x1, y1, x2, y2],同样使用归一化坐标 [0-1000]
以 JSON 数组形式输出,例如:
[
{
"name": "某某充电站",
"address": "某某路 100 号",
"point": [500, 750],
"bbox": [50, 600, 950, 820]
}
]
"""
try:
res_text = await self.vlm.analyze_image(image_path, prompt)
@@ -279,82 +273,79 @@ class ReadImageKit:
vlm_results = json.loads(json_str)
final_stations = []
if cv_bboxes and isinstance(vlm_results, list):
img = read_image(image_path)
h, w = img.shape[:2] if img is not None else (2400, 1080)
y_threshold = h * 0.58
bottom_threshold = int(h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO))
use_bboxes = []
for b in cv_bboxes:
cy = (b[1] + b[3]) // 2
if cy >= y_threshold:
use_bboxes.append(b)
for res in vlm_results:
if not res:
vlm_list = vlm_results if isinstance(vlm_results, list) else []
img = read_image(image_path)
h, w = img.shape[:2] if img is not None else (2400, 1080)
y_threshold = h * SAFE_EXCLUDE_RATIO
bottom_threshold = int(h * (1 - BOTTOM_SAFE_EXCLUDE_RATIO))
def to_pixel(v, max_len):
if v is None:
return None
try:
fv = float(v)
except:
return None
if fv <= 1000.0:
return int(fv * max_len / 1000.0)
return int(fv)
for res in vlm_list:
if not res:
continue
p = res.get("point")
b = res.get("bbox")
actual_p = None
actual_bbox = None
if p and len(p) == 2:
px = to_pixel(p[0], w)
py = to_pixel(p[1], h)
if px is not None and py is not None:
actual_p = [px, py]
if b and len(b) == 4:
x1 = to_pixel(b[0], w)
y1 = to_pixel(b[1], h)
x2 = to_pixel(b[2], w)
y2 = to_pixel(b[3], h)
if None not in (x1, y1, x2, y2):
actual_bbox = [max(0, int(x1)), max(0, int(y1)), min(w, int(x2)), min(h, int(y2))]
if actual_p is None and actual_bbox:
actual_p = [(actual_bbox[0] + actual_bbox[2]) // 2, (actual_bbox[1] + actual_bbox[3]) // 2]
if actual_p is None and actual_bbox is None:
continue
if actual_p and (actual_p[1] < y_threshold or actual_p[1] > bottom_threshold):
logger.warning(f"过滤掉可能的误点击点 (y={actual_p[1]}): {res.get('name')}")
continue
if actual_bbox is None and actual_p:
half_w = int(w * 0.4)
half_h = max(MIN_CARD_HEIGHT // 2, 90)
x1 = max(0, actual_p[0] - half_w)
x2 = min(w, actual_p[0] + half_w)
y1 = max(0, actual_p[1] - half_h)
y2 = min(h, actual_p[1] + half_h)
actual_bbox = [x1, y1, x2, y2]
if actual_bbox is not None:
bx1, by1, bx2, by2 = actual_bbox
current_h = by2 - by1
if current_h < MIN_CARD_HEIGHT * 0.8 or by2 > bottom_threshold:
continue
p = res.get("point")
actual_p = None
if p and len(p) == 2:
actual_p = p
if p[0] <= 1000 and p[1] <= 1000:
actual_p = [int(p[0] * w / 1000), int(p[1] * h / 1000)]
# 顶部与底部安全过滤
if actual_p and (actual_p[1] < y_threshold or actual_p[1] > bottom_threshold):
logger.warning(f"过滤掉可能的误点击点 (y={actual_p[1]} < {y_threshold}): {res.get('name')}")
continue
matched_bbox = None
if actual_p:
for b in use_bboxes:
if b[0] <= actual_p[0] <= b[2] and b[1] <= actual_p[1] <= b[3]:
matched_bbox = b
break
# 如果没有匹配到 CV 框,则根据点击点生成一个虚拟框
if matched_bbox is None and actual_p is not None:
half_w = int(w * 0.4)
half_h = 90
x1 = max(0, actual_p[0] - half_w)
x2 = min(w, actual_p[0] + half_w)
y1 = max(0, actual_p[1] - half_h)
y2 = min(h, actual_p[1] + half_h)
matched_bbox = [x1, y1, x2, y2]
# 如果既没有 point 也没有匹配框,跳过
if actual_p is None and matched_bbox is None:
continue
if actual_p is None and matched_bbox:
actual_p = [(matched_bbox[0] + matched_bbox[2]) // 2, (matched_bbox[1] + matched_bbox[3]) // 2]
final_stations.append({
"name": res.get("name"),
"address": res.get("address"),
"point": actual_p,
"bbox": matched_bbox
})
elif not cv_bboxes:
vlm_list = vlm_results if isinstance(vlm_results, list) else []
w, h = 0, 0
for res in vlm_list:
p = res.get("point")
if p and len(p) == 2:
# 1. 坐标转换逻辑
actual_p = p
if p[0] <= 1000 and p[1] <= 1000:
if w == 0:
img = read_image(image_path)
if img is not None:
h, w = img.shape[:2]
else:
w, h = 1080, 2400 # 兜底
actual_p = [int(p[0] * w / 1000), int(p[1] * h / 1000)]
# 2. 坐标安全过滤:忽略页面上半部分的误点击(通常是 Logo 或广告)
# 即使 VLM 没听话,我们也在这里硬性过滤
# 根据主屏幕截图,顶部广告区和功能图标区占用了约 55% 的空间
y_threshold = h * 0.58 if h > 0 else 1400
if actual_p[1] < y_threshold:
logger.warning(f"过滤掉可能的误点击点 (y={actual_p[1]} < {y_threshold}): {res.get('name')}")
continue
res["point"] = actual_p
final_stations.append(res)
final_stations.append({
"name": res.get("name"),
"address": res.get("address"),
"point": actual_p,
"bbox": actual_bbox
})
if final_stations:
final_stations.sort(key=lambda s: (s.get("point")[1] if s.get("point") else (s.get("bbox")[1] if s.get("bbox") else 0)))
# 保存诊断图片 (_vl.jpg, _flag.jpg)
if final_stations: