ai_helper_vision.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. """
  2. AI 视觉助手 — 方案A:截图直接送视觉大模型识别
  3. =================================================
  4. 跳过本地 OCR,把整张截图直接给视觉模型,让它同时完成"识字 + 结构化 + 定位坐标"。
  5. 返回格式与 ai_helper1.AIParser.parse_shops 完全一致,可直接替换。
  6. 用法(在 main1.py 里替换 _get_named_shops 的 AI 调用):
  7. from ai_helper_vision import VisionParser
  8. parser = VisionParser()
  9. shops = parser.parse_shops(shot_path, screen_size=(w, h), keyword=keyword)
  10. 独立测试:
  11. python ai_helper_vision.py <截图路径>
  12. """
  13. import base64
  14. import json
  15. import os
  16. import re
  17. import sys
  18. import time
  19. from pathlib import Path
  20. from typing import Optional
  21. import requests
  22. # ── 配置 ────────────────────────────────────────────────
  23. # 视觉引擎开关:
  24. # True = 本地 HunyuanOCR(llama-server,http://127.0.0.1:8080,OpenAI兼容,免费/离线)
  25. # False = DeepSeek 官网视觉 API(云端,收费,需 API Key)
  26. USE_LOCAL_HUNYUAN = True
  27. # 本地 HunyuanOCR(需先启动 llama-server:双击 D:\HunyuanOCR\start_gpu.bat)
  28. HY_API_URL = "http://127.0.0.1:8080/v1/chat/completions"
  29. HY_MODEL = "HYVL"
  30. # DeepSeek 官网 API(OpenAI 兼容格式,非 Anthropic 格式)
  31. DS_API_URL = "https://api.deepseek.com/v1/chat/completions"
  32. # 可设环境变量 VISION_MODEL 覆盖 DeepSeek 模型名
  33. DS_VISION_MODEL = os.environ.get("VISION_MODEL", "deepseek-v4-flash-vision-exp")
  34. # 当前生效的后端(由开关决定)
  35. API_URL = HY_API_URL if USE_LOCAL_HUNYUAN else DS_API_URL
  36. VISION_MODEL = HY_MODEL if USE_LOCAL_HUNYUAN else DS_VISION_MODEL
  37. # 图片过大时缩放到这个宽度(视觉模型对超长图支持差,2712px 长图容易漏识别底部卡片)
  38. MAX_IMAGE_WIDTH = 1280
  39. PROJECT_ROOT = Path(__file__).parent
  40. class VisionParser:
  41. """截图直接送视觉模型,返回结构化店铺列表"""
  42. # API Key 直接写死在文件里
  43. _API_KEY = "sk-08f45783a60f4b3183deef41d321474d"
  44. def __init__(self, api_key: Optional[str] = None):
  45. if USE_LOCAL_HUNYUAN:
  46. # 本地 llama-server 不校验 Key,传占位即可
  47. self.api_key = api_key or "not-needed"
  48. else:
  49. self.api_key = api_key or self._API_KEY
  50. if not self.api_key:
  51. raise RuntimeError("未设置 API Key")
  52. # ── 核心:截图 → 视觉模型 → 店铺列表 ───────────────
  53. def parse_shops(
  54. self,
  55. image_path: str,
  56. screen_size: tuple = (1220, 2712),
  57. keyword: str = "",
  58. crop_x: int = 0,
  59. ) -> list:
  60. """
  61. 截图送视觉模型,返回结构化店铺列表。
  62. Args:
  63. image_path: 截图文件路径
  64. screen_size: (宽, 高),截图的原始尺寸,用于坐标校验
  65. keyword: 当前搜索的商品关键词
  66. crop_x: 左列商品图片的分界x,>0时只裁右列(文字区)给AI,去掉商品图片
  67. Returns:
  68. [[店铺名, 商品标题, 价格, [点击x, 点击y], 店铺名y, 月售量], ...]
  69. 点击坐标由「标题y 和 店铺名y 的中点」算出,不直接让AI返回坐标
  70. """
  71. screen_w, screen_h = screen_size
  72. # 1. 读取图片,裁掉左列商品图片(只留右列文字区),超宽时缩放
  73. img_data, media_type, scale, crop_w = self._encode_image(image_path, screen_w, crop_x)
  74. # 2. 构造 prompt
  75. system_prompt = self._system_prompt(keyword)
  76. crop_desc = f"已裁掉左侧商品图片,只保留右侧文字区(宽{crop_w}px)" if crop_x else "完整截图"
  77. user_prompt = (
  78. f"这是一张饿了么闪购App搜索结果截图({crop_desc},高度{screen_h}px)。"
  79. f"请找出屏幕上所有商品卡片,按格式返回。\n"
  80. f"当前搜索的商品是「{keyword}」。"
  81. )
  82. # 3. 调 API(带图片)
  83. print(f"[ai-vision] 识别图片: {os.path.basename(image_path)} ({screen_w}x{screen_h}, crop_x={crop_x})...")
  84. response = self._call(system_prompt, user_prompt, img_data, media_type)
  85. # 4. 解析返回,点击坐标由标题y和店铺名y的中点算出
  86. result = self._parse_response(response, screen_w, screen_h, scale, crop_x, crop_w)
  87. print(f"[ai-vision] 解析到 {len(result)} 个店铺")
  88. return result
  89. # ── 图片编码 + 缩放 ──────────────────────────────
  90. def _encode_image(self, image_path: str, screen_w: int, crop_x: int = 0):
  91. """读取图片,裁掉左列商品图片(crop_x>0时只留右列),超宽时缩放。
  92. 返回 (base64_data, media_type, scale, crop_width)"""
  93. global MAX_IMAGE_WIDTH
  94. try:
  95. from PIL import Image
  96. import io
  97. img = Image.open(image_path)
  98. w, h = img.size
  99. # 裁掉左列商品图片,只留右列文字区
  100. if crop_x and crop_x > 0 and crop_x < w:
  101. img = img.crop((crop_x, 0, w, h))
  102. w = img.size[0]
  103. print(f"[ai-vision] 裁掉左列商品图片: 只留右列文字区 (宽{w}px)")
  104. scale = 1.0
  105. if w > MAX_IMAGE_WIDTH:
  106. scale = MAX_IMAGE_WIDTH / w
  107. new_w = MAX_IMAGE_WIDTH
  108. new_h = int(h * scale)
  109. img = img.resize((new_w, new_h), Image.LANCZOS)
  110. print(f"[ai-vision] 图片缩放: {w}x{h} → {new_w}x{new_h} (scale={scale:.3f})")
  111. # 转 PNG(统一格式,避免 JPEG 压缩干扰文字识别)
  112. buf = io.BytesIO()
  113. img.save(buf, format="PNG")
  114. data = base64.b64encode(buf.getvalue()).decode("utf-8")
  115. return data, "image/png", scale, w
  116. except ImportError:
  117. # 没有 PIL,直接读原始文件(无法裁剪)
  118. with open(image_path, "rb") as f:
  119. data = base64.b64encode(f.read()).decode("utf-8")
  120. ext = os.path.splitext(image_path)[1].lower()
  121. media = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg"}.get(ext.lstrip("."), "image/png")
  122. return data, media, 1.0, screen_w
  123. # ── system prompt ────────────────────────────────
  124. @staticmethod
  125. def _system_prompt(keyword: str) -> str:
  126. return f"""你是手机截图分析助手。收到一张饿了么闪购App的搜索结果截图(已裁掉左侧商品图片,只剩右侧文字区),需要找出每个商品卡片的信息。
  127. 【重要:当前搜索的商品】
  128. 用户正在搜索「{keyword}」。请严格遵守:
  129. - 商品标题优先取与「{keyword}」匹配的文字行(含品牌名和规格)
  130. - 功能描述(如"养阴敛肺""解热镇痛""清热解毒""止咳化痰"等)字数少、无品牌名、在多个卡片中重复 → 绝对不能作为商品标题
  131. - 找不到匹配时,商品标题取卡片中最像完整商品名的文字(含品牌+药名+规格结构)
  132. - 如果整个页面与「{keyword}」无关(如显示快餐/外卖商家),返回空数组 []
  133. 【卡片布局 — 从上到下】
  134. 1. 商品标题(最上方,通常以品牌标签开头,如[理洫王]、[999]、[以岭]、[康恩贝],后面跟药名+规格)
  135. 2. 功能描述(短句,如"养阴敛肺",可忽略)
  136. 3. 价格(¥或¥开头)
  137. 4. 月售/已售(如"月售6000""已售92")
  138. 5. 店铺名(以"店/药房/大药房/旗舰店"结尾)
  139. 【字段规则】
  140. 1. 店铺名 = 卡片最下方像店名的文字。含"("只取"("之前部分。
  141. 2. 商品标题 = 卡片最上方的完整商品名称,**必须包含开头的品牌标签**(如"[理洫王]血塞通软胶囊0.33g*24粒/盒",不能把[理洫王]丢掉)。
  142. 品牌标签和后面的药名+规格要完整拼接在一起,不要截断、不要去掉品牌。
  143. 3. 价格 = 卡片中带¥或¥的文字,只取数值+货币符号(如"¥12.8")。
  144. 4. 点击坐标[x,y]:
  145. - x = 卡片文字区的水平中间位置(图片宽度的中间左右)
  146. - y = **该卡片「商品标题文字」和「店铺名文字」的垂直中点**(标题在卡片上方,店铺名在卡片下方,取两者y坐标的中间值)
  147. - 这个坐标用于点击进入该商品,请尽量准确
  148. 5. 店铺名y = 店铺名文字所在位置的y坐标。
  149. 6. 月售量 = 含"月售"或"已售"文字里的数字部分。如"月售6000"→"6000"、"已售92"→"92"。找不到留空字符串""。
  150. 【约束】
  151. - 坐标基于你看到的图片实际像素尺寸(图片宽度即文字区宽度)
  152. - 店铺名和商品标题不能是同一个文字(如果是,说明没有独立标题,跳过该卡片)
  153. - 有多少个卡片就输出多少个,不要遗漏,不要多加
  154. - 输出纯JSON,不要任何多余文字
  155. - **所有字符串值必须用双引号包裹**,如"灵露大药房"不能写成灵露大药房
  156. 返回格式(JSON对象数组,所有字符串加双引号):
  157. [{{"shop":"店铺名(去括号)", "title":"商品标题(完整含品牌)", "price":"价格", "click":{{"x":点击x, "y":点击y}}, "shop_y":店铺名y, "sales":"月售量"}}, ...]
  158. 示例:
  159. [{{"shop":"灵露大药房", "title":"[理洫王]血塞通软胶囊0.33g*60粒/盒", "price":"¥42.6", "click":{{"x":368,"y":285}}, "shop_y":439, "sales":""}}]"""
  160. # ── API 调用(带图片)──────────────────────────────
  161. def _call(
  162. self,
  163. system_prompt: str,
  164. user_text: str,
  165. img_data: str,
  166. media_type: str,
  167. timeout: int = 300 if USE_LOCAL_HUNYUAN else 120,
  168. retries: int = 3,
  169. ) -> str:
  170. """调用视觉 API(本地 HunyuanOCR 或 DeepSeek,均为 OpenAI 兼容格式,content 含 image_url 块)"""
  171. # OpenAI 兼容格式:data URL 内嵌 base64 图片
  172. data_url = f"data:{media_type};base64,{img_data}"
  173. headers = {
  174. "Authorization": f"Bearer {self.api_key}",
  175. "Content-Type": "application/json",
  176. }
  177. payload = {
  178. "model": VISION_MODEL,
  179. "max_tokens": 8192,
  180. "messages": [
  181. {"role": "system", "content": system_prompt},
  182. {
  183. "role": "user",
  184. "content": [
  185. {"type": "text", "text": user_text},
  186. {"type": "image_url", "image_url": {"url": data_url}},
  187. ],
  188. },
  189. ],
  190. }
  191. last_err = None
  192. for attempt in range(retries):
  193. try:
  194. resp = requests.post(API_URL, json=payload, headers=headers, timeout=timeout)
  195. if resp.status_code != 200:
  196. # 打印HTTP错误码和响应体,方便排查
  197. print(f" [ai-vision] HTTP {resp.status_code}: {resp.text[:300]}")
  198. resp.raise_for_status()
  199. data = resp.json()
  200. # 检查API是否返回了错误(如模型不存在、限流等)
  201. if "error" in data:
  202. err_msg = data["error"].get("message", str(data["error"])) if isinstance(data["error"], dict) else str(data["error"])
  203. print(f" [ai-vision] API返回错误: {err_msg[:300]}")
  204. last_err = Exception(err_msg)
  205. if attempt < retries - 1:
  206. time.sleep(2)
  207. continue
  208. # OpenAI 格式:choices[0].message.content
  209. content = data.get("choices", [{}])[0].get("message", {}).get("content", "") or ""
  210. if not content:
  211. print(f" [ai-vision] 返回内容为空,完整响应: {str(data)[:300]}")
  212. return content
  213. except Exception as e:
  214. last_err = e
  215. if attempt < retries - 1:
  216. print(f" [ai-vision] API调用失败(第{attempt+1}次),重试... ({e})")
  217. time.sleep(2)
  218. print(f" [ai-vision] API调用{retries}次均失败,跳过: {last_err}")
  219. return ""
  220. # ── 解析返回 ────────────────────────────────────
  221. @staticmethod
  222. def _parse_response(response: str, screen_w: int, screen_h: int,
  223. scale: float = 1.0, crop_x: int = 0, crop_w: int = 0) -> list:
  224. """解析 AI 返回的 JSON,坐标按缩放比例还原 + 加裁剪偏移"""
  225. if not response.strip():
  226. print("[ai-vision] 返回为空")
  227. return []
  228. cleaned = response.strip()
  229. # 去掉 markdown 代码块
  230. if cleaned.startswith("```"):
  231. lines = cleaned.split("\n")
  232. if lines[0].startswith("```"):
  233. lines = lines[1:]
  234. if lines and lines[-1].strip() == "```":
  235. lines = lines[:-1]
  236. cleaned = "\n".join(lines).strip()
  237. try:
  238. data = json.loads(cleaned)
  239. except json.JSONDecodeError:
  240. match = re.search(r'\[[\s\S]*\]', cleaned)
  241. if match:
  242. try:
  243. data = json.loads(match.group(0))
  244. except json.JSONDecodeError:
  245. # JSON解析失败(AI没给字符串加引号),直接按字符串分割提取
  246. print(f"[ai-vision] JSON解析失败,改用字符串分割提取...")
  247. return VisionParser._loose_parse(cleaned, screen_w, screen_h, scale, crop_x)
  248. else:
  249. print(f"[ai-vision] 未找到 JSON 数组")
  250. return []
  251. if not isinstance(data, list):
  252. print(f"[ai-vision] 返回不是数组")
  253. return []
  254. # 缩放还原:模型看到的是缩放后的图,坐标要除以scale还原
  255. # x 还要加 crop_x 偏移(因为图被裁掉了左列,AI的x是相对裁剪后的)
  256. def _restore(v):
  257. return int(round(v / scale)) if scale and scale != 1.0 else int(v)
  258. validated = []
  259. for item in data:
  260. # 兼容 dict 格式
  261. if isinstance(item, dict):
  262. shop = str(item.get("name") or item.get("shop") or item.get("shop_name") or "")
  263. title = str(item.get("title") or item.get("product_name") or "")
  264. price = str(item.get("price") or item.get("min_price") or "")
  265. coords = item.get("coords") or item.get("click_pos") or item.get("click")
  266. if not isinstance(coords, list) and item.get("x") is not None and item.get("y") is not None:
  267. coords = [item.get("x"), item.get("y")]
  268. shop_name_y = item.get("shop_name_y") or item.get("y")
  269. sales = str(item.get("sales") or item.get("month_sales") or "")
  270. item = [shop, title, price, coords, shop_name_y, sales]
  271. if not isinstance(item, list) or len(item) < 4:
  272. continue
  273. shop = str(item[0]) if item[0] else ""
  274. title = str(item[1]) if item[1] else ""
  275. price = str(item[2]) if item[2] else ""
  276. coords = item[3]
  277. if isinstance(coords, list) and len(coords) == 2:
  278. # x: 先还原缩放,再加裁剪偏移 → 绝对屏幕坐标
  279. cx = _restore(coords[0]) + crop_x
  280. # y: 只还原缩放(裁剪是水平的,y不变)
  281. cy = _restore(coords[1])
  282. # 坐标校验(还原后在原始屏幕尺寸范围内)
  283. if 0 <= cx <= screen_w and 0 <= cy <= screen_h:
  284. shop_name_y = _restore(item[4]) if len(item) >= 5 and isinstance(item[4], (int, float)) else min(cy + 200, screen_h)
  285. sales = str(item[5]) if len(item) >= 6 and item[5] not in (None, "") else ""
  286. validated.append([shop, title, price, [cx, cy], shop_name_y, sales])
  287. return validated
  288. @staticmethod
  289. def _loose_parse(text: str, screen_w: int, screen_h: int,
  290. scale: float = 1.0, crop_x: int = 0) -> list:
  291. """JSON解析失败时,用字符串分割提取店铺数据。
  292. 格式固定: [[店铺名, 标题, 价格, [x, y], shop_y, 月售], ...]
  293. 不用正则,纯字符串操作。"""
  294. text = text.strip()
  295. # 去掉最外层方括号
  296. if text.startswith('['):
  297. text = text[1:]
  298. if text.endswith(']'):
  299. text = text[:-1]
  300. # 按 ], [ 分割成每个店铺条目
  301. entries = text.split('], [')
  302. def _restore(v):
  303. return int(round(v / scale)) if scale and scale != 1.0 else int(v)
  304. shops = []
  305. for entry in entries:
  306. entry = entry.strip().lstrip('[').rstrip(']')
  307. # 找价格标记(¥或¥),用它定位标题和价格的分界
  308. price_idx = -1
  309. for marker in ('¥', '¥'):
  310. idx = entry.find(marker)
  311. if idx >= 0:
  312. price_idx = idx
  313. break
  314. if price_idx < 0:
  315. continue
  316. # 价格之前: "店铺名, 标题"
  317. before = entry[:price_idx].strip().rstrip(',').strip()
  318. comma_idx = before.find(',')
  319. if comma_idx < 0:
  320. continue
  321. shop = before[:comma_idx].strip().strip('"')
  322. title = before[comma_idx + 1:].strip().strip('"')
  323. # 价格之后: "¥42.6, [368, 285], 439, """
  324. rest = entry[price_idx:]
  325. parts = rest.split(',')
  326. # parts: ['¥42.6', ' [368', ' 285]', ' 439', ' ""']
  327. if len(parts) < 5:
  328. continue
  329. try:
  330. price = parts[0].strip()
  331. x = _restore(int(parts[1].strip().lstrip('[')))
  332. y = _restore(int(parts[2].strip().rstrip(']')))
  333. shop_y = _restore(int(parts[3].strip()))
  334. sales = parts[4].strip().strip('"') if len(parts) > 4 else ""
  335. except (ValueError, IndexError):
  336. continue
  337. x += crop_x
  338. if 0 <= x <= screen_w and 0 <= y <= screen_h:
  339. shops.append([shop, title, price, [x, y], shop_y, sales])
  340. print(f"[ai-vision] 字符串分割提取到 {len(shops)} 个店铺")
  341. return shops
  342. # ── 独立测试 ───────────────────────────────────────────
  343. if __name__ == "__main__":
  344. # 用法: python ai_helper_vision.py <截图路径> [关键词]
  345. if len(sys.argv) < 2:
  346. print("用法: python ai_helper_vision.py <截图路径> [关键词]")
  347. print("示例: python ai_helper_vision.py D:\\drug\\sg\\screenshots\\step3_b0.png 强力枇杷露")
  348. sys.exit(1)
  349. img_path = sys.argv[1]
  350. kw = sys.argv[2] if len(sys.argv) > 2 else "D:\drug\sg\screenshots\SK4T6XZH4PEUOZ99\misc\check_pos.png"
  351. if not os.path.exists(img_path):
  352. print(f"文件不存在: {img_path}")
  353. sys.exit(1)
  354. # 获取图片原始尺寸
  355. try:
  356. from PIL import Image
  357. w, h = Image.open(img_path).size
  358. except ImportError:
  359. w, h = 1220, 2712
  360. parser = VisionParser()
  361. shops = parser.parse_shops(img_path, screen_size=(w, h), keyword=kw)
  362. print("\n" + "=" * 60)
  363. print(f" 识别到 {len(shops)} 个店铺")
  364. print("=" * 60)
  365. for i, s in enumerate(shops, 1):
  366. print(f" [{i}] 店铺: {s[0]}")
  367. print(f" 商品: {s[1]}")
  368. print(f" 价格: {s[2]}")
  369. print(f" 点击: ({s[3][0]}, {s[3][1]})")
  370. print(f" 月售: {s[5]}")
  371. print()