tmp_captcha_test6.py 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166
  1. """test6 — 方案一:连续轨迹、分阶段定时、TouchPipe 与到位轮询。"""
  2. import sys, os, json, time, random, math, threading
  3. import uiautomator2 as u2
  4. from uiautomator2.core import AdbHTTPConnection
  5. import cv2, numpy as np, base64, requests
  6. from rapidocr_onnxruntime import RapidOCR
  7. os.environ["PYTHONIOENCODING"] = "utf-8"
  8. sys.path.insert(0, r"D:\drug\sg")
  9. from detect_slider_button import detect_slider_button
  10. from detect_captcha_edge import detect_captcha_left_edge
  11. ocr_eng = RapidOCR()
  12. TOKEN = "1nDVocTE2mJ0yLEYb2sZJ5uUY2VIEoGTkIpW44X7Kgk"
  13. JFBYM_URL = "http://api.jfbym.com/api/YmServer/customApi"
  14. BASE = os.path.dirname(os.path.abspath(__file__))
  15. IMG_ROOT = os.path.join(BASE, "image", "test6_image")
  16. OUT = os.path.join(IMG_ROOT, "d")
  17. ALIGN_DIR = os.path.join(IMG_ROOT, "align")
  18. # 按天分类:success/failure 下按日期建子目录(如 success/2026-08-19/xxx.png)
  19. _TODAY = time.strftime("%Y-%m-%d")
  20. SUCCESS_DIR = os.path.join(IMG_ROOT, "success", _TODAY)
  21. FAILURE_DIR = os.path.join(IMG_ROOT, "failure", _TODAY)
  22. os.makedirs(OUT, exist_ok=True)
  23. os.makedirs(ALIGN_DIR, exist_ok=True)
  24. os.makedirs(SUCCESS_DIR, exist_ok=True)
  25. os.makedirs(FAILURE_DIR, exist_ok=True)
  26. OFFSET_COMPENSATE = 0
  27. MICRO_MODE = "none"
  28. # 真人速度模板的慢速尾段原本约占最后 10% 路程。运行时把这段压缩到
  29. # 最后 3%~5%,让前段继续快速靠近,离缺口很近后才明显减速。
  30. RETURN_BRAKE_SOURCE_START = 0.90
  31. RETURN_BRAKE_START_RANGE = (0.95, 0.97)
  32. # 折回主段使用固定的快速节奏,不再直接照搬某一条可能很慢的真人模板。
  33. # 采样间隔保持在触摸事件可稳定消费的范围,距离越长只增加点数,不拉长点间隔。
  34. RETURN_SAMPLE_INTERVAL_RANGE = (0.0042, 0.0052)
  35. RETURN_BRAKE_TIME_RANGE = (0.58, 0.66)
  36. RETURN_CURVE_LIMIT_1220 = 42
  37. RETURN_Y_CORRIDOR_1220 = 46
  38. RETURN_END_DRIFT_LIMIT_1220 = 28
  39. # 方案一参数:按真人样本的量级修正点密度与阶段时长。
  40. RIGHT_SAMPLE_INTERVAL_RANGE = (0.0038, 0.0052)
  41. RIGHT_POINT_LIMITS = (60, 110)
  42. # 右滑移动本身按 test5 的真人节奏控制;到最右端后的幕布展开等待
  43. # 由 RIGHT_SETTLE_RANGE 单独负责,不计入右滑阶段。
  44. RIGHT_DURATION_RANGE = (0.25, 0.40)
  45. TRACK_SKIP_LATE_S = 0.010
  46. # 真人样本的右端折返点集中在约 1023~1109 px;不要每次都固定在 1190。
  47. # 下限略高于滑块可确认的右端,避免随机到太靠左导致幕布未完全展开。
  48. RIGHT_END_RANGE_1220 = (1060, 1105)
  49. # 真人右滑大多是轻微向上收尾(Y 减小),按水平距离归一化。
  50. RIGHT_Y_DRIFT_RATIO_RANGE = (-0.055, -0.018)
  51. # TouchPipe 异步发送后,至少给幕布动画和设备事件队列留出稳定时间。
  52. RIGHT_SETTLE_RANGE = (0.55, 0.85)
  53. RIGHT_READY_TIMEOUT_S = 3.50
  54. RETURN_READY_TIMEOUT_S = 1.80
  55. SLIDER_POSITION_TOLERANCE = 12
  56. # 真人轨迹模板目录。模板只使用归一化后的形状,不直接复用原始触摸坐标,
  57. # 因此不会把旧设备的绝对坐标带到当前屏幕。
  58. GUIJI_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "guiji")
  59. _GUIJI_TEMPLATES = None
  60. def _clamp(v, lo, hi):
  61. return max(lo, min(v, hi))
  62. def _detect_slider_center(image):
  63. """从 OpenCV 截图中检测橙色滑块中心。"""
  64. if image is None or getattr(image, "ndim", 0) != 3:
  65. return None
  66. rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
  67. hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
  68. mask = cv2.inRange(hsv, np.array([8, 230, 230]), np.array([22, 255, 255]))
  69. h, _ = mask.shape[:2]
  70. mask[:int(h * 0.55), :] = 0
  71. kernel = np.ones((5, 5), np.uint8)
  72. mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
  73. mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
  74. contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  75. candidates = []
  76. for cnt in contours:
  77. x, y, bw, bh = cv2.boundingRect(cnt)
  78. area = bw * bh
  79. if area < 300 or bw < 30 or bh < 30:
  80. continue
  81. aspect = min(bw, bh) / max(bw, bh)
  82. if aspect < 0.5:
  83. continue
  84. candidates.append((area * aspect, x, bw))
  85. if not candidates:
  86. return None
  87. _, x, bw = max(candidates, key=lambda item: item[0])
  88. return x + bw / 2.0
  89. def _wait_for_slider_position(pipe, device, x, y, expected_center=None,
  90. min_center=None, timeout_s=3.0, label="",
  91. track_pts=None, t0=None, phase="confirm",
  92. stable_required=2):
  93. """重复发送终点并轮询真实滑块位置,避免截图/松手早于异步事件落地。"""
  94. started = time.perf_counter()
  95. stable = 0
  96. last_center = None
  97. last_frame = None
  98. while time.perf_counter() - started < timeout_s:
  99. send_ms = 0.0
  100. try:
  101. send_ms = pipe.move(int(x), int(y), 0)
  102. except Exception:
  103. pass
  104. if track_pts is not None:
  105. track_pts.append({
  106. "x": int(x), "y": int(y), "pressure": 0,
  107. "phase": phase, "confirm": True,
  108. "scheduled_ms": None, "lag_ms": None,
  109. "send_ms": send_ms,
  110. "rel_ms": ((time.perf_counter() - t0) * 1000
  111. if t0 is not None else None),
  112. })
  113. time.sleep(0.06)
  114. try:
  115. frame = device.screenshot(format="opencv")
  116. except Exception:
  117. frame = None
  118. if frame is not None:
  119. last_frame = frame
  120. center = _detect_slider_center(frame)
  121. if center is not None:
  122. last_center = center
  123. if expected_center is not None:
  124. ok = abs(center - expected_center) <= SLIDER_POSITION_TOLERANCE
  125. elif min_center is not None:
  126. ok = center >= min_center
  127. else:
  128. ok = True
  129. stable = stable + 1 if ok else 0
  130. if stable >= stable_required:
  131. elapsed = (time.perf_counter() - started) * 1000
  132. return last_frame, last_center, elapsed, True
  133. time.sleep(0.06)
  134. elapsed = (time.perf_counter() - started) * 1000
  135. if label:
  136. actual = "未检测到" if last_center is None else f"{last_center:.0f}"
  137. target = (f"{expected_center:.0f}" if expected_center is not None
  138. else f">={min_center:.0f}" if min_center is not None else "任意")
  139. print(f" {label}确认超时: actual={actual}, target={target}")
  140. return last_frame, last_center, elapsed, False
  141. def _dedupe_track(points):
  142. """移除取整后相邻的重复坐标,避免末端连续发送同一个点。"""
  143. result = []
  144. for x, y in points:
  145. point = (int(x), int(y))
  146. if not result or point != result[-1]:
  147. result.append(point)
  148. return result
  149. def _pressure_curve(i, n, phase):
  150. # uiautomator2 injectInputEvent 的第四参数是 metaState,不是压力;保持为 0。
  151. return 0
  152. def _choose_right_end_x(start_x, screen_w):
  153. """返回接近真人分布、且仍能完全揭开幕布的右侧折返点。"""
  154. scale = screen_w / 1220.0
  155. min_travel = 800 * scale
  156. lo = int(round(RIGHT_END_RANGE_1220[0] * scale))
  157. hi = int(round(RIGHT_END_RANGE_1220[1] * scale))
  158. sampled = random.uniform(lo, hi)
  159. return int(round(_clamp(sampled, start_x + min_travel, screen_w - 12)))
  160. def _return_duration(distance, scale):
  161. """生成快速折回总时长;接口等待和右端展开等待不计入移动时间。"""
  162. # 先用较高的巡航速度完成大部分距离,再为最后3%~5%保留短暂收尾。
  163. # 速度按屏幕比例归一化,避免长距离被旧模板拖到1.5秒以上。
  164. speed = random.uniform(1450.0, 1750.0) * max(scale, 0.8)
  165. if distance > 600 * scale:
  166. tail = random.uniform(0.18, 0.24)
  167. lo, hi = 0.62, 0.88
  168. elif distance > 450 * scale:
  169. tail = random.uniform(0.16, 0.22)
  170. lo, hi = 0.48, 0.70
  171. else:
  172. tail = random.uniform(0.14, 0.20)
  173. lo, hi = 0.34, 0.52
  174. return _clamp(distance / speed + tail, lo, hi)
  175. def _timing_schedule(count, duration_s, phase):
  176. """生成总时长固定、点间隔轻微相关的发送时间表。"""
  177. if count <= 1:
  178. return [0.0]
  179. phase_shift = random.uniform(0, math.tau)
  180. weights = []
  181. for i in range(count - 1):
  182. t = i / max(1, count - 2)
  183. correlated = 0.10 * math.sin(math.tau * (1.2 * t) + phase_shift)
  184. jitter = random.uniform(-0.05, 0.05)
  185. if phase == 'return' and t > 0.75:
  186. correlated += 0.08 * (t - 0.75) / 0.25
  187. weights.append(max(0.70, 1.0 + correlated + jitter))
  188. total = sum(weights)
  189. elapsed = 0.0
  190. schedule = [0.0]
  191. for weight in weights:
  192. elapsed += duration_s * weight / total
  193. schedule.append(elapsed)
  194. schedule[-1] = duration_s
  195. return schedule
  196. def _emit_track(pipe, track, phase, track_pts, t0, duration_s,
  197. schedule_override=None):
  198. """按绝对时间表发送;落后过多时丢弃过期中间点,终点始终发送。"""
  199. if not track:
  200. return 0.0, {
  201. "planned_points": 0, "sent_points": 0, "skipped_points": 0,
  202. "target_ms": duration_s * 1000, "elapsed_ms": 0.0,
  203. "lag_p95_ms": 0.0, "lag_max_ms": 0.0,
  204. "send_p95_ms": 0.0, "send_max_ms": 0.0,
  205. }
  206. started = time.perf_counter()
  207. if schedule_override is not None and len(schedule_override) == len(track):
  208. schedule = list(schedule_override)
  209. schedule[0] = 0.0
  210. schedule[-1] = duration_s
  211. else:
  212. schedule = _timing_schedule(len(track), duration_s, phase)
  213. lag_samples = []
  214. send_samples = []
  215. skipped = 0
  216. for i, (x, y) in enumerate(track):
  217. deadline = started + schedule[i]
  218. wait_s = deadline - time.perf_counter()
  219. if wait_s > 0:
  220. time.sleep(wait_s)
  221. lag_s = max(0.0, time.perf_counter() - deadline)
  222. # 中间点已经过期时继续补发只会制造事件突发;最后一点不能跳过。
  223. if i < len(track) - 1 and lag_s > TRACK_SKIP_LATE_S:
  224. skipped += 1
  225. continue
  226. pressure = _pressure_curve(i, len(track), phase)
  227. send_ms = pipe.move(x, y, pressure)
  228. lag_ms = max(0.0, (time.perf_counter() - deadline) * 1000)
  229. lag_samples.append(lag_ms)
  230. send_samples.append(send_ms)
  231. track_pts.append({"x": x, "y": y, "pressure": pressure,
  232. "phase": phase,
  233. "scheduled_ms": schedule[i] * 1000,
  234. "lag_ms": lag_ms, "send_ms": send_ms,
  235. "rel_ms": (time.perf_counter()-t0)*1000})
  236. elapsed_s = time.perf_counter() - started
  237. def percentile(values, ratio):
  238. if not values:
  239. return 0.0
  240. ordered = sorted(values)
  241. return ordered[int(round((len(ordered) - 1) * ratio))]
  242. stats = {
  243. "planned_points": len(track),
  244. "sent_points": len(track) - skipped,
  245. "skipped_points": skipped,
  246. "target_ms": duration_s * 1000,
  247. "elapsed_ms": elapsed_s * 1000,
  248. "lag_p95_ms": percentile(lag_samples, 0.95),
  249. "lag_max_ms": max(lag_samples, default=0.0),
  250. "send_p95_ms": percentile(send_samples, 0.95),
  251. "send_max_ms": max(send_samples, default=0.0),
  252. }
  253. return elapsed_s, stats
  254. class TouchPipe:
  255. def __init__(self, dev):
  256. self._dev = dev; self._conn = None; self._sock = None
  257. self._stop = threading.Event(); self._drainer = None
  258. self._lock = threading.Lock(); self._fallback = False
  259. self._open_ok = False; self._open_error = None
  260. self._fallback_reason = None; self._last_error = None
  261. self._send_errors = 0; self._move_errors = 0
  262. self._pipe_move_ms = []; self._fallback_move_ms = []
  263. def open(self):
  264. try:
  265. self._conn = AdbHTTPConnection(self._dev.adb_device, port=9008)
  266. self._conn.timeout = 15; self._conn.connect()
  267. self._sock = self._conn.sock; self._sock.settimeout(0.5)
  268. self._drainer = threading.Thread(target=self._drain, daemon=True)
  269. self._drainer.start()
  270. self._open_ok = True
  271. except Exception as exc:
  272. self._fallback = True
  273. self._fallback_reason = "open_error"
  274. self._open_error = repr(exc)
  275. self._last_error = repr(exc)
  276. return self
  277. def _drain(self):
  278. while not self._stop.is_set():
  279. try:
  280. if not self._sock.recv(65536): break
  281. except Exception: continue
  282. def move(self, x, y, pressure=0):
  283. if self._fallback:
  284. started = time.perf_counter()
  285. try:
  286. self._dev.touch.move(int(x), int(y))
  287. except Exception as exc:
  288. self._move_errors += 1
  289. self._last_error = repr(exc)
  290. elapsed_ms = (time.perf_counter() - started) * 1000
  291. self._fallback_move_ms.append(elapsed_ms)
  292. return elapsed_ms
  293. started = time.perf_counter()
  294. try:
  295. with self._lock:
  296. self._sock.sendall(self._req(x, y, pressure))
  297. elapsed_ms = (time.perf_counter() - started) * 1000
  298. self._pipe_move_ms.append(elapsed_ms)
  299. return elapsed_ms
  300. except Exception as exc:
  301. self._send_errors += 1
  302. self._last_error = repr(exc)
  303. self._fallback = True
  304. self._fallback_reason = "send_error"
  305. return self.move(x, y, pressure)
  306. def _req(self, x, y, pressure):
  307. body = json.dumps({"jsonrpc":"2.0","id":1,"method":"injectInputEvent",
  308. "params":[2,int(x),int(y),0]}).encode()
  309. return (f"POST /jsonrpc/0 HTTP/1.1\r\nHost: localhost\r\n"
  310. f"User-Agent: u2\r\nAccept-Encoding: \r\n"
  311. f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n"
  312. f"Connection: keep-alive\r\n\r\n").encode() + body
  313. def close(self):
  314. self._stop.set()
  315. if self._drainer: self._drainer.join(timeout=1)
  316. try:
  317. if self._sock: self._sock.close()
  318. except Exception: pass
  319. def diagnostics(self):
  320. def percentile(values, ratio):
  321. if not values:
  322. return 0.0
  323. ordered = sorted(values)
  324. return ordered[int(round((len(ordered) - 1) * ratio))]
  325. return {
  326. "open_ok": self._open_ok,
  327. "fallback_used": self._fallback,
  328. "fallback_reason": self._fallback_reason,
  329. "open_error": self._open_error,
  330. "last_error": self._last_error,
  331. "send_errors": self._send_errors,
  332. "move_errors": self._move_errors,
  333. "pipe_moves": len(self._pipe_move_ms),
  334. "fallback_moves": len(self._fallback_move_ms),
  335. "pipe_send_p50_ms": percentile(self._pipe_move_ms, 0.50),
  336. "pipe_send_p95_ms": percentile(self._pipe_move_ms, 0.95),
  337. "pipe_send_max_ms": max(self._pipe_move_ms, default=0.0),
  338. "fallback_move_p95_ms": percentile(self._fallback_move_ms, 0.95),
  339. "fallback_move_max_ms": max(self._fallback_move_ms, default=0.0),
  340. }
  341. def _print_touchpipe_diagnostics(pipe):
  342. diag = pipe.diagnostics()
  343. print(f" TouchPipe: open={'OK' if diag['open_ok'] else 'FAIL'} "
  344. f"fallback={diag['fallback_used']} errors={diag['send_errors']} "
  345. f"send95={diag['pipe_send_p95_ms']:.2f}ms "
  346. f"sendMax={diag['pipe_send_max_ms']:.2f}ms")
  347. if diag["last_error"]:
  348. print(f" TouchPipe最后错误: {diag['last_error']}")
  349. return diag
  350. def _normalize_phase_template(points, phase):
  351. """Convert one recorded guiji phase into a device-independent curve.
  352. u is horizontal progress (0..1); residual is the signed distance from the
  353. straight endpoint chord divided by the horizontal span. This keeps the
  354. shape reusable on a different screen size and avoids copying raw touch
  355. coordinates from the recording device.
  356. """
  357. if not points or len(points) < 12:
  358. return None
  359. xy = [(float(p.get("x", 0)), float(p.get("y", 0))) for p in points]
  360. if phase == "right":
  361. split = max(range(len(xy)), key=lambda i: xy[i][0])
  362. xy = xy[:split + 1]
  363. x0, x1 = xy[0][0], xy[-1][0]
  364. span = x1 - x0
  365. if span < 1000:
  366. return None
  367. raw_u = [(x - x0) / span for x, _ in xy]
  368. else:
  369. split = max(range(len(xy)), key=lambda i: xy[i][0])
  370. xy = xy[split:]
  371. if len(xy) < 12:
  372. return None
  373. x0, x1 = xy[0][0], xy[-1][0]
  374. span = x0 - x1
  375. if span < 800:
  376. return None
  377. raw_u = [(x0 - x) / span for x, _ in xy]
  378. # End-of-phase recordings can contain a few backtracking pixels. Preserve
  379. # the broad human curve but make the interpolation coordinate monotonic.
  380. raw_u = np.maximum.accumulate(np.asarray(raw_u, dtype=np.float64))
  381. raw_u = np.clip(raw_u, 0.0, 1.0)
  382. unique_u, unique_idx = np.unique(raw_u, return_index=True)
  383. if len(unique_u) < 8:
  384. return None
  385. yy = np.asarray([xy[i][1] for i in unique_idx], dtype=np.float64)
  386. sample_u = np.linspace(0.0, 1.0, 81)
  387. sample_y = np.interp(sample_u, unique_u, yy)
  388. chord_y = sample_y[0] + (sample_y[-1] - sample_y[0]) * sample_u
  389. residual = (sample_y - chord_y) / span
  390. residual[0] = 0.0
  391. residual[-1] = 0.0
  392. # A few recordings contain unusually large endpoint excursions. They are
  393. # valid data, but clipping keeps one outlier from producing an unsafe path.
  394. residual = np.clip(residual, -0.18, 0.18)
  395. return {
  396. "u": sample_u.tolist(),
  397. "residual": residual.tolist(),
  398. "curvature": float(np.max(np.abs(residual))),
  399. "raw_points": len(xy),
  400. }
  401. def _normalize_return_velocity_template(points):
  402. """Extract horizontal progress as a function of elapsed return time."""
  403. if not points or len(points) < 20:
  404. return None
  405. turn_idx = max(range(len(points)), key=lambda i: float(points[i].get("x", 0)))
  406. turn_x = float(points[turn_idx].get("x", 0))
  407. end_x = float(points[-1].get("x", 0))
  408. full_span = turn_x - end_x
  409. if full_span < 500:
  410. return None
  411. threshold = max(10.0, full_span * 0.01)
  412. start_idx = None
  413. for i in range(turn_idx + 1, len(points)):
  414. if turn_x - float(points[i].get("x", 0)) >= threshold:
  415. start_idx = i
  416. break
  417. if start_idx is None or len(points) - start_idx < 12:
  418. return None
  419. phase = points[start_idx:]
  420. x0 = float(phase[0].get("x", 0))
  421. x1 = float(phase[-1].get("x", 0))
  422. span = x0 - x1
  423. t0 = float(phase[0].get("rel_ms", 0.0))
  424. t1 = float(phase[-1].get("rel_ms", 0.0))
  425. duration_ms = t1 - t0
  426. if span < 300 or duration_ms < 100:
  427. return None
  428. raw_t = np.asarray([
  429. (float(p.get("rel_ms", 0.0)) - t0) / duration_ms for p in phase
  430. ], dtype=np.float64)
  431. raw_u = np.asarray([
  432. (x0 - float(p.get("x", 0))) / span for p in phase
  433. ], dtype=np.float64)
  434. raw_t = np.clip(raw_t, 0.0, 1.0)
  435. raw_u = np.maximum.accumulate(np.clip(raw_u, 0.0, 1.0))
  436. unique_t, unique_idx = np.unique(raw_t, return_index=True)
  437. if len(unique_t) < 8:
  438. return None
  439. unique_u = raw_u[unique_idx]
  440. # The recorder often keeps sending the final coordinate after the slider
  441. # has already reached the target. That endpoint plateau is a hold phase,
  442. # not return motion, so trim it from the velocity template.
  443. end_idx = next((i for i, value in enumerate(unique_u)
  444. if value >= 0.999), len(unique_u) - 1)
  445. motion_end_t = max(float(unique_t[end_idx]), 1e-6)
  446. event_t = unique_t[:end_idx + 1] / motion_end_t
  447. event_progress = unique_u[:end_idx + 1]
  448. event_t[0] = 0.0
  449. event_t[-1] = 1.0
  450. event_progress[0] = 0.0
  451. event_progress[-1] = 1.0
  452. sample_t = np.linspace(0.0, 1.0, 101)
  453. sample_u = np.interp(sample_t, event_t, event_progress)
  454. sample_u[0] = 0.0
  455. sample_u[-1] = 1.0
  456. return {
  457. "t": sample_t.tolist(),
  458. "progress": sample_u.tolist(),
  459. "event_t": event_t.tolist(),
  460. "event_progress": event_progress.tolist(),
  461. "duration_ms": duration_ms * motion_end_t,
  462. "plateau_ms": duration_ms * (1.0 - motion_end_t),
  463. "raw_points": len(event_t),
  464. }
  465. def _load_guiji_templates():
  466. global _GUIJI_TEMPLATES
  467. if _GUIJI_TEMPLATES is not None:
  468. return _GUIJI_TEMPLATES
  469. templates = {
  470. "right": [], "short": [], "medium": [], "long": [],
  471. "velocity": {"short": [], "medium": [], "long": []},
  472. }
  473. try:
  474. names = sorted(n for n in os.listdir(GUIJI_ROOT) if n.lower().endswith(".json"))
  475. except OSError:
  476. names = []
  477. for name in names:
  478. try:
  479. with open(os.path.join(GUIJI_ROOT, name), "r", encoding="utf-8") as fh:
  480. record = json.load(fh)
  481. points = record.get("points") or []
  482. right = _normalize_phase_template(points, "right")
  483. ret = _normalize_phase_template(points, "return")
  484. velocity = _normalize_return_velocity_template(points)
  485. if right:
  486. templates["right"].append(right)
  487. if ret:
  488. span = max(float(points[i]["x"]) for i in range(len(points))) - float(points[-1]["x"])
  489. if span < 6300:
  490. key = "short"
  491. elif span < 8400:
  492. key = "medium"
  493. else:
  494. key = "long"
  495. templates[key].append(ret)
  496. if velocity:
  497. templates["velocity"][key].append(velocity)
  498. except (OSError, ValueError, KeyError, TypeError):
  499. continue
  500. # The medium bucket has few observations, so use all return templates as a
  501. # safe fallback instead of inventing an unrelated synthetic curve.
  502. all_returns = templates["short"] + templates["medium"] + templates["long"]
  503. for key in ("short", "medium", "long"):
  504. if not templates[key]:
  505. templates[key] = all_returns[:]
  506. all_velocity = (templates["velocity"]["short"]
  507. + templates["velocity"]["medium"]
  508. + templates["velocity"]["long"])
  509. for key in ("short", "medium", "long"):
  510. if not templates["velocity"][key]:
  511. templates["velocity"][key] = all_velocity[:]
  512. _GUIJI_TEMPLATES = templates
  513. print(" 真人模板: right={} short={} medium={} long={} velocity={}/{}/{}".format(
  514. len(templates["right"]), len(templates["short"]),
  515. len(templates["medium"]), len(templates["long"]),
  516. len(templates["velocity"]["short"]),
  517. len(templates["velocity"]["medium"]),
  518. len(templates["velocity"]["long"])))
  519. return templates
  520. def _template_residual(template, t):
  521. if not template:
  522. return 0.0
  523. return float(np.interp(float(t), template["u"], template["residual"]))
  524. def _template_progress(template, t):
  525. if not template:
  526. # Smooth fallback with a short acceleration section and a long braking
  527. # tail. Real templates are available in normal operation.
  528. t = _clamp(float(t), 0.0, 1.0)
  529. knots_t = np.asarray([0.0, 0.08, 0.20, 0.35, 0.55, 0.75, 0.90, 1.0])
  530. knots_u = np.asarray([0.0, 0.07, 0.23, 0.43, 0.63, 0.80, 0.93, 1.0])
  531. return float(np.interp(t, knots_t, knots_u))
  532. return float(np.interp(float(t), template["t"], template["progress"]))
  533. def _compress_return_braking_tail(progress, brake_start):
  534. """把模板最后 10% 的慢速路程压缩到最后 3%~5%,保持时间顺序不变。"""
  535. values = np.asarray(progress, dtype=np.float64).copy()
  536. source = RETURN_BRAKE_SOURCE_START
  537. target = float(_clamp(brake_start, source + 0.01, 0.99))
  538. before = values <= source
  539. values[before] *= target / source
  540. values[~before] = (
  541. target
  542. + (values[~before] - source) * (1.0 - target) / (1.0 - source)
  543. )
  544. values = np.maximum.accumulate(np.clip(values, 0.0, 1.0))
  545. values[0] = 0.0
  546. values[-1] = 1.0
  547. return values
  548. def _build_fast_return_progress(count, brake_start, brake_time):
  549. """前段快速匀速推进,最后一小段才进入明显的减速/微调。"""
  550. if count <= 1:
  551. return np.asarray([0.0]), np.asarray([0.0])
  552. event_t = np.linspace(0.0, 1.0, int(count), dtype=np.float64)
  553. event_u = np.empty_like(event_t)
  554. main = event_t <= brake_time
  555. main_t = np.clip(event_t[main] / max(brake_time, 1e-6), 0.0, 1.0)
  556. # 轻微的自然起步,不制造旧模板那种长时间慢爬。
  557. event_u[main] = brake_start * np.power(main_t, 0.96)
  558. tail_t = np.clip(
  559. (event_t[~main] - brake_time) / max(1.0 - brake_time, 1e-6),
  560. 0.0, 1.0,
  561. )
  562. # 刚进入最后一段时仍有少量位移,随后逐步减小到目标。
  563. event_u[~main] = brake_start + (1.0 - brake_start) * (
  564. 1.0 - np.power(1.0 - tail_t, 2.2)
  565. )
  566. event_u[0] = 0.0
  567. event_u[-1] = 1.0
  568. return event_t, np.maximum.accumulate(np.clip(event_u, 0.0, 1.0))
  569. def _fallback_bow(t, category, amplitude=None):
  570. """Low-frequency fallback with the same distance-dependent curvature."""
  571. ratios = {"short": (0.020, 0.055), "medium": (0.035, 0.075),
  572. "long": (0.050, 0.115)}
  573. lo, hi = ratios[category]
  574. amp = amplitude
  575. if amp is None:
  576. amp = random.uniform(lo, hi) * random.choice((-1.0, 1.0))
  577. # One broad asymmetric bow; no high-frequency wobble.
  578. return amp * math.sin(math.pi * t) * (0.88 + 0.24 * t)
  579. def _build_right_track(start_x, start_y, end_x, point_count):
  580. """右滑轨迹:连续低频起伏,避免逐点随机造成锯齿。"""
  581. dist = end_x - start_x
  582. if dist <= 0:
  583. return [(int(start_x), int(start_y))]
  584. # Use a normalized guiji curve for the broad motion. The old branch below
  585. # is retained as unreachable reference code while the new generator is
  586. # validated against saved images.
  587. steps = max(1, int(point_count) - 1)
  588. templates = _load_guiji_templates().get("right", [])
  589. template = random.choice(templates) if templates else None
  590. template_sign = random.choice((-1.0, 1.0))
  591. template_gain = random.uniform(0.82, 1.08)
  592. fallback_amp = random.uniform(0.020, 0.055) * random.choice((-1.0, 1.0))
  593. # 真人右滑通常是轻微向上(Y 减小),不再生成明显向下的漂移。
  594. drift = random.uniform(*RIGHT_Y_DRIFT_RATIO_RANGE) * dist
  595. ease_exp = random.uniform(1.65, 2.05)
  596. points = [(int(start_x), int(start_y))]
  597. for i in range(1, steps + 1):
  598. t = i / steps
  599. u = 1.0 - (1.0 - t) ** ease_exp
  600. x = start_x + dist * u
  601. residual = (_template_residual(template, u) * template_sign * template_gain
  602. if template else _fallback_bow(u, "short", fallback_amp))
  603. residual = _clamp(residual, -0.06, 0.06)
  604. y = start_y + drift * u + residual * dist
  605. points.append((int(round(x)), int(round(y))))
  606. points[-1] = (int(end_x), points[-1][1])
  607. return _dedupe_track(points)
  608. # Legacy procedural branch kept below for easy rollback during validation.
  609. steps = max(1, int(point_count) - 1)
  610. form = random.choices(
  611. ['flat', 'arch', 'decline', 'slope'],
  612. weights=[0.25, 0.35, 0.20, 0.20],
  613. k=1,
  614. )[0]
  615. drift = random.triangular(-70, 95, 22)
  616. if abs(drift) < 18:
  617. drift = 18 * random.choice([-1, 1])
  618. arch_h = random.triangular(18, 70, 38) * random.choice([-1, 1])
  619. decline_dy = random.triangular(20, 90, 45)
  620. slope_dy = random.triangular(-75, 100, 25)
  621. ease_exp = random.uniform(1.65, 2.15)
  622. wobble_amp = random.uniform(1.5, 5.0)
  623. wobble_cycles = random.uniform(1.0, 2.2)
  624. wobble_phase = random.uniform(0, math.tau)
  625. points = [(int(start_x), int(start_y))]
  626. for i in range(1, steps + 1):
  627. t = i / steps
  628. x = start_x + dist * (1.0 - (1.0 - t) ** ease_exp)
  629. if form == 'flat':
  630. y = start_y + drift * t
  631. elif form == 'arch':
  632. y = start_y + drift * t + arch_h * math.sin(math.pi * t)
  633. elif form == 'decline':
  634. y = start_y + drift * t + abs(arch_h) * math.sin(math.pi * t) + decline_dy * (t ** 2)
  635. else: # slope
  636. y = start_y + slope_dy * t
  637. y += (wobble_amp * math.sin(math.tau * wobble_cycles * t + wobble_phase)
  638. * math.sin(math.pi * t))
  639. points.append((int(round(x)), int(round(y))))
  640. points[-1] = (int(end_x), points[-1][1])
  641. return _dedupe_track(points)
  642. def _build_human_return_track(start_x, start_y, target_x,
  643. duration_s=None, return_schedule=False):
  644. """折返轨迹:按距离调整点密度,并保留轻微的平滑回修。"""
  645. distance = start_x - target_x
  646. if distance <= 0:
  647. result = [(int(target_x), int(start_y))]
  648. return (result, [0.0]) if return_schedule else result
  649. scale = W / 1220.0
  650. if distance > 600 * scale:
  651. cat = "long"
  652. end_ratio = random.triangular(-0.045, 0.12, 0.035)
  653. elif distance > 450 * scale:
  654. cat = "medium"
  655. end_ratio = random.triangular(-0.035, 0.10, 0.025)
  656. else:
  657. cat = "short"
  658. end_ratio = random.triangular(-0.025, 0.08, 0.018)
  659. if duration_s is None:
  660. duration_s = _return_duration(distance, scale)
  661. templates = _load_guiji_templates()
  662. geometry_templates = templates.get(cat, [])
  663. geometry = random.choice(geometry_templates) if geometry_templates else None
  664. end_dy_limit = int(round(RETURN_END_DRIFT_LIMIT_1220 * scale))
  665. end_dy = int(_clamp(round(end_ratio * distance), -end_dy_limit, end_dy_limit))
  666. target_y = start_y + end_dy
  667. shape_gain = random.uniform(0.82, 1.08)
  668. shape_sign = random.choice((-1.0, 1.0))
  669. curve_limit_px = {
  670. "short": 30.0,
  671. "medium": 36.0,
  672. "long": float(RETURN_CURVE_LIMIT_1220),
  673. }[cat] * scale
  674. residual_limit = min(
  675. {"short": 0.06, "medium": 0.075, "long": 0.115}[cat],
  676. curve_limit_px / max(distance, 1.0),
  677. )
  678. fallback_lo = min({"short": 0.020, "medium": 0.035, "long": 0.050}[cat],
  679. residual_limit)
  680. fallback_hi = min({"short": 0.055, "medium": 0.075, "long": 0.115}[cat],
  681. residual_limit)
  682. fallback_amp = random.uniform(fallback_lo, max(fallback_lo, fallback_hi)) \
  683. * random.choice((-1.0, 1.0))
  684. interval = random.uniform(*RETURN_SAMPLE_INTERVAL_RANGE)
  685. steps = int(_clamp(round(duration_s / interval) + 1,
  686. {"short": 60, "medium": 90, "long": 120}[cat],
  687. {"short": 180, "medium": 230, "long": 300}[cat]))
  688. brake_start = random.uniform(*RETURN_BRAKE_START_RANGE)
  689. brake_time = random.uniform(*RETURN_BRAKE_TIME_RANGE)
  690. event_t, event_u = _build_fast_return_progress(
  691. steps, brake_start, brake_time
  692. )
  693. track = []
  694. max_residual = 0.0
  695. max_y_deviation = 0.0
  696. y_corridor = RETURN_Y_CORRIDOR_1220 * scale
  697. for t, u in zip(event_t, event_u):
  698. residual = (_template_residual(geometry, u) * shape_sign * shape_gain
  699. if geometry else _fallback_bow(float(u), cat, fallback_amp))
  700. residual = _clamp(residual, -residual_limit, residual_limit)
  701. max_residual = max(max_residual, abs(residual))
  702. x = start_x - distance * float(u)
  703. y = start_y + end_dy * float(u) + residual * distance
  704. y = _clamp(y, start_y - y_corridor, start_y + y_corridor)
  705. max_y_deviation = max(max_y_deviation, abs(y - start_y))
  706. track.append((int(round(x)), int(round(y))))
  707. if track:
  708. track[0] = (int(start_x), int(start_y))
  709. track[-1] = (int(target_x), int(target_y))
  710. # 取整后相同的坐标不再重复发送;下一次不同坐标的时间仍保留,
  711. # 这样末端是短暂停顿而不是高频轰炸同一个像素。
  712. compact_track = []
  713. compact_t = []
  714. for point, t in zip(track, event_t):
  715. if not compact_track or point != compact_track[-1]:
  716. compact_track.append(point)
  717. compact_t.append(float(t))
  718. track = compact_track
  719. event_t = np.asarray(compact_t, dtype=np.float64)
  720. if len(event_t) > 1:
  721. event_t[0] = 0.0
  722. event_t[-1] = 1.0
  723. schedule = (event_t * float(duration_s)).tolist()
  724. print(f" 折回: {cat} distance={distance:.0f}px {len(track)}点 "
  725. f"Y漂={end_dy:+d} 弯曲={max_residual * distance:.0f}px "
  726. f"比例={max_residual * 100:.1f}% Y范围={max_y_deviation:.0f}px "
  727. f"速度=快速分段 时长={duration_s * 1000:.0f}ms "
  728. f"快速段={brake_time * duration_s * 1000:.0f}ms "
  729. f"减速起点={brake_start * 100:.1f}% "
  730. f"尾段={(1.0 - brake_start) * 100:.1f}%")
  731. return (track, schedule) if return_schedule else track
  732. # Legacy procedural branch kept below for rollback during validation.
  733. if distance > 600 * scale:
  734. cat = "long"
  735. steps = int(_clamp(distance / random.uniform(2.8, 4.1), 180, 380))
  736. end_ratio = random.triangular(-0.045, 0.12, 0.035)
  737. elif distance > 450 * scale:
  738. cat = "medium"
  739. steps = int(_clamp(distance / random.uniform(2.7, 4.2), 130, 250))
  740. end_ratio = random.triangular(-0.035, 0.10, 0.025)
  741. else:
  742. cat = "short"
  743. steps = int(_clamp(distance / random.uniform(2.0, 3.8), 75, 210))
  744. end_ratio = random.triangular(-0.025, 0.08, 0.018)
  745. end_dy = int(round(end_ratio * distance))
  746. templates = _load_guiji_templates().get(cat, [])
  747. template = random.choice(templates) if templates else None
  748. shape_gain = random.uniform(0.80, 1.12)
  749. shape_sign = random.choice((-1.0, 1.0))
  750. fallback_amp = random.uniform(
  751. {"short": 0.020, "medium": 0.035, "long": 0.050}[cat],
  752. {"short": 0.055, "medium": 0.075, "long": 0.115}[cat],
  753. ) * random.choice((-1.0, 1.0))
  754. x_exp = random.uniform(1.55, 2.05)
  755. linear_tail = random.uniform(0.16, 0.24)
  756. target_y = start_y + end_dy
  757. track = []
  758. max_residual = 0.0
  759. for i in range(steps):
  760. t = i / max(1, steps - 1)
  761. # Horizontal movement eases into the target. The residual is a
  762. # single broad bow learned from guiji, rather than random wobble.
  763. ease = 1.0 - (1.0 - t) ** x_exp
  764. u = ease * (1.0 - linear_tail) + t * linear_tail
  765. x = start_x - distance * u
  766. residual = (_template_residual(template, u) * shape_sign * shape_gain
  767. if template else _fallback_bow(u, cat, fallback_amp))
  768. residual_limit = {"short": 0.06, "medium": 0.075, "long": 0.115}[cat]
  769. residual = _clamp(residual, -residual_limit, residual_limit)
  770. max_residual = max(max_residual, abs(residual))
  771. chord_y = start_y + end_dy * u
  772. y = chord_y + residual * distance
  773. track.append((int(round(x)), int(round(y))))
  774. if track:
  775. track[-1] = (int(target_x), int(target_y))
  776. print(f" 折回: {cat} distance={distance:.0f}px {len(track)}点 "
  777. f"Y漂={end_dy:+d} 弯曲={max_residual * distance:.0f}px "
  778. f"比例={max_residual * 100:.1f}%")
  779. return _dedupe_track(track)
  780. # Legacy endpoint-easing branch kept below for rollback during validation.
  781. scale = W / 1220.0
  782. if distance > 600 * scale:
  783. # Keep the long return close to the slider rail. A 300+ px Y drift
  784. # puts the final events outside the control and the device stops
  785. # applying the horizontal motion reliably.
  786. steps = int(_clamp(distance / random.uniform(4.0, 6.0), 150, 210))
  787. end_dy = int(round(random.triangular(60, 180, 110) * scale))
  788. cat = '长折'
  789. elif distance > 450 * scale:
  790. steps = int(_clamp(distance / random.uniform(3.0, 5.0), 110, 180))
  791. end_dy = int(round(random.triangular(-25, 45, 10) * scale))
  792. cat = '中折'
  793. else:
  794. steps = int(_clamp(distance / random.uniform(1.2, 2.2), 80, 240))
  795. end_dy = int(round(random.triangular(0, 135, 40) * scale))
  796. cat = '短折'
  797. x_exp = random.uniform(1.55, 2.10)
  798. y_exp = random.uniform(1.15, 1.85)
  799. print(f' 折回: {cat} distance={distance:.0f}px {steps}点 Y漂=+{end_dy} '
  800. f'X弧度={x_exp:.2f} Y弧度={y_exp:.2f}')
  801. target_y = start_y + end_dy
  802. track = []
  803. for i in range(1, steps + 1):
  804. t = i / steps
  805. # 末端保留 12% 线性分量,避免取整后长时间停在同一坐标。
  806. ease = 1 - (1 - t) ** x_exp
  807. x = start_x - distance * (ease * 0.88 + t * 0.12)
  808. y = start_y + (target_y - start_y) * (1 - (1 - t) ** y_exp)
  809. track.append((int(round(x)), int(round(y))))
  810. if track:
  811. track[-1] = (int(target_x), int(target_y))
  812. track = _dedupe_track(track)
  813. return _dedupe_track(track)
  814. def save_track_image(pts, filepath):
  815. if len(pts) < 2: return
  816. xs=[p[0] for p in pts]; ys=[p[1] for p in pts]; m=50
  817. w=max(xs)-min(xs)+m*2; h=max(ys)-min(ys)+m*2
  818. w,h=max(w,200),max(h,100)
  819. c=np.ones((h,w,3),dtype=np.uint8)*255
  820. for i in range(1,len(pts)):
  821. r=i/len(pts); g=200 if r<0.5 else int(200*(1-r)*2); b=int(200*r*2) if r<0.5 else 200
  822. cv2.line(c,(pts[i-1][0]-min(xs)+m,pts[i-1][1]-min(ys)+m),
  823. (pts[i][0]-min(xs)+m,pts[i][1]-min(ys)+m),(0,g,b),1)
  824. cv2.circle(c,(pts[0][0]-min(xs)+m,pts[0][1]-min(ys)+m),4,(0,200,0),-1)
  825. cv2.circle(c,(pts[-1][0]-min(xs)+m,pts[-1][1]-min(ys)+m),4,(0,0,200),-1)
  826. cv2.imwrite(filepath,c)
  827. def solve_slider(driver, sx=None):
  828. global d, W, H
  829. d = driver; W, H = d.window_size(); d.screen_on()
  830. if sx is None:
  831. sx = 163 if W > 1000 else 87
  832. print(f" 屏幕: {W}x{H} -> sx={sx}")
  833. screen = d.screenshot(format="opencv")
  834. slider_y = y_top = slider_bottom = None
  835. try:
  836. cv2.imwrite(os.path.join(OUT, "_tmp_slider.png"), screen)
  837. info = detect_slider_button(os.path.join(OUT, "_tmp_slider.png"))
  838. coords = info[0] if isinstance(info, tuple) else info
  839. if isinstance(coords, dict):
  840. sx = int(round((coords["top_left"][0] + coords["bottom_right"][0]) / 2))
  841. except: pass
  842. initial_button_center = _detect_slider_center(screen)
  843. handle_center_offset = ((initial_button_center - sx)
  844. if initial_button_center is not None else 0.0)
  845. ocr_r = ocr_eng(screen)
  846. if ocr_r and ocr_r[0]:
  847. for item in ocr_r[0]:
  848. t = item[1]; cy = int((item[0][0][1]+item[0][2][1])/2)
  849. if "请按照说明拖动滑块" in t: slider_y = slider_y or cy; slider_bottom = int(item[0][2][1])
  850. if "松开" in t: y_top = int(item[0][0][1])
  851. if slider_y is None: return False
  852. sy = slider_y
  853. # 右滑 — TouchPipe;injectInputEvent 第四参数固定为 0
  854. right_end_x = _choose_right_end_x(sx, W)
  855. print(f" 右滑: 按住({sx},{sy}) -> 折返点({right_end_x})")
  856. touch_started = time.perf_counter()
  857. d.touch.down(sx, sy)
  858. down_hold_s = random.uniform(0.09, 0.15)
  859. time.sleep(down_hold_s)
  860. pipe = TouchPipe(d).open()
  861. t0 = time.perf_counter(); track_pts = []
  862. right_target_s = random.uniform(*RIGHT_DURATION_RANGE)
  863. right_sample_interval_s = random.uniform(*RIGHT_SAMPLE_INTERVAL_RANGE)
  864. right_point_count = int(round(_clamp(
  865. right_target_s / right_sample_interval_s + 1,
  866. RIGHT_POINT_LIMITS[0], RIGHT_POINT_LIMITS[1],
  867. )))
  868. right_track = _build_right_track(
  869. sx, sy, right_end_x, right_point_count
  870. )
  871. right_elapsed_s, right_emit = _emit_track(
  872. pipe, right_track, 'right', track_pts, t0, right_target_s
  873. )
  874. right_emit["requested_points"] = right_point_count
  875. right_emit["sample_interval_ms"] = right_sample_interval_s * 1000
  876. right_finished = time.perf_counter()
  877. print(f" 右滑: 计划{len(right_track)}点/实发{right_emit['sent_points']}点 "
  878. f"跳过{right_emit['skipped_points']}点 {right_elapsed_s*1000:.0f}ms "
  879. f"lag95={right_emit['lag_p95_ms']:.1f}ms")
  880. settle_s = random.uniform(*RIGHT_SETTLE_RANGE)
  881. time.sleep(settle_s)
  882. # TouchPipe 的请求可能仍在设备端排队。重复发送右端点并以真实滑块
  883. # 中心确认到右侧后,才截取给接口的图片,避免幕布尚未展开。
  884. # 手指可以发送到 1190,但滑块按钮受滑轨右边界限制:1220 宽屏上
  885. # 滑轨约止于 1130,154px 宽的按钮中心最大约为 1053。
  886. # 因此右端确认必须按 UI 的物理极限判断,不能按手指折返点判断。
  887. scale = W / 1220.0
  888. right_min_center = max(0, int(round(W - 180 * scale)))
  889. unfolded, right_center, right_ready_ms, right_ready = _wait_for_slider_position(
  890. pipe, d, right_end_x, right_track[-1][1],
  891. track_pts=track_pts, t0=t0, phase="right_confirm",
  892. min_center=right_min_center, timeout_s=RIGHT_READY_TIMEOUT_S,
  893. label="右端到位"
  894. )
  895. print(f" 右端确认: center="
  896. f"{right_center if right_center is not None else '未检测到'} "
  897. f"目标>={right_min_center} wait={right_ready_ms:.0f}ms "
  898. f"{'OK' if right_ready else 'TIMEOUT'}")
  899. if not right_ready:
  900. print(f" 右端到位未确认,仍使用最后截图 center="
  901. f"{right_center if right_center is not None else '未检测到'}")
  902. if unfolded is None:
  903. unfolded = d.screenshot(format="opencv")
  904. if not right_ready:
  905. cv2.imwrite(os.path.join(OUT, "_unfold_not_ready.png"), unfolded)
  906. print(" 幕布未确认完全展开,本次不调用接口,避免使用错误坐标")
  907. _print_touchpipe_diagnostics(pipe)
  908. pipe.close()
  909. d.touch.up(int(right_end_x), int(right_track[-1][1]))
  910. return False
  911. # JFBYM
  912. # 先做一次渲染/事件队列冲刷,再截取真正提交给识别接口的画面。
  913. time.sleep(random.uniform(0.06, 0.12))
  914. crop = d.screenshot(format="opencv")
  915. if y_top and slider_bottom: crop = crop[y_top:slider_bottom, :]
  916. _, buf = cv2.imencode(".png", crop)
  917. gap = None
  918. api_started = time.perf_counter()
  919. for a in range(3):
  920. try:
  921. r = requests.post(JFBYM_URL, json={"token":TOKEN,"type":"20226","image":base64.b64encode(buf).decode()}, timeout=35).json()
  922. if r.get("data") and r["data"].get("data"): gap = int(r["data"]["data"])
  923. elif r.get("data") and isinstance(r["data"],(int,float)): gap = int(r["data"])
  924. if gap is not None: break
  925. time.sleep(2)
  926. except: time.sleep(2)
  927. api_elapsed_s = time.perf_counter() - api_started
  928. if gap is None:
  929. _print_touchpipe_diagnostics(pipe)
  930. pipe.close()
  931. d.touch.up(*right_track[-1])
  932. return False
  933. print(f" gap={gap}")
  934. # 折回 — 按距离确定点数和阶段时长
  935. turn_x, turn_y = right_track[-1]
  936. raw_target_x = gap + OFFSET_COMPENSATE
  937. target_x = int(_clamp(raw_target_x, 10, turn_x - 1))
  938. if target_x != raw_target_x:
  939. print(f" 目标X超出折返范围: {raw_target_x} -> {target_x}")
  940. return_target_s = _return_duration(turn_x - target_x, W / 1220.0)
  941. return_track, return_schedule = _build_human_return_track(
  942. turn_x, turn_y, target_x, return_target_s, return_schedule=True
  943. )
  944. return_target_s = float(return_schedule[-1]) if return_schedule else return_target_s
  945. hold_ms = (time.perf_counter() - right_finished) * 1000
  946. return_elapsed_s, return_emit = _emit_track(
  947. pipe, return_track, 'return', track_pts, t0, return_target_s,
  948. schedule_override=return_schedule
  949. )
  950. return_emit["schedule_mode"] = "fast_then_brake"
  951. print(f" 折回: 计划{len(return_track)}点/实发{return_emit['sent_points']}点 "
  952. f"跳过{return_emit['skipped_points']}点 {return_elapsed_s*1000:.0f}ms "
  953. f"lag95={return_emit['lag_p95_ms']:.1f}ms 远端停顿={hold_ms:.0f}ms")
  954. # 回滑同样不能把“最后一个已发送点”当成“设备已经到位”。
  955. # 轮询真实滑块中心,并重复发送目标点,直到旧事件队列被消化。
  956. target_center = target_x + handle_center_offset
  957. return_ready_frame, return_center, return_ready_ms, return_ready = (
  958. _wait_for_slider_position(
  959. pipe, d, target_x, return_track[-1][1],
  960. track_pts=track_pts, t0=t0, phase="return_confirm",
  961. expected_center=target_center, timeout_s=RETURN_READY_TIMEOUT_S,
  962. stable_required=1,
  963. label="回滑到位"
  964. )
  965. )
  966. if not return_ready:
  967. print(f" 回滑到位未确认,释放前实际 center="
  968. f"{return_center if return_center is not None else '未检测到'}")
  969. phase_timing = {
  970. "down_hold_ms": down_hold_s * 1000,
  971. "right_ms": right_elapsed_s * 1000,
  972. "right_emit": right_emit,
  973. "settle_ms": settle_s * 1000,
  974. "right_ready_ms": right_ready_ms,
  975. "right_ready": right_ready,
  976. "right_center": right_center,
  977. "api_ms": api_elapsed_s * 1000,
  978. "hold_ms": hold_ms,
  979. "return_ms": return_elapsed_s * 1000,
  980. "return_emit": return_emit,
  981. "return_ready_ms": return_ready_ms,
  982. "return_ready": return_ready,
  983. "return_center": return_center,
  984. }
  985. # 到位处理:以滑块按钮的真实中心确认,不再把幕布橙色左边缘
  986. # 直接和 API gap 相减。两者不是同一个物理坐标点。
  987. alignment_started = time.perf_counter()
  988. aligned = return_ready
  989. last_diff = None
  990. correction_count = 0
  991. cur_x, cur_y = return_track[-1]
  992. print(f" 回滑确认: 滑块中心="
  993. f"{return_center if return_center is not None else '未检测到'} "
  994. f"目标中心={target_center:.0f} wait={return_ready_ms:.0f}ms "
  995. f"{'OK' if return_ready else 'TIMEOUT'}")
  996. aligned_img = return_ready_frame
  997. # 微调(默认关闭;如果启用 fixed,仍以 API 目标点为释放点)
  998. if MICRO_MODE == "fixed":
  999. target_cur_x = int(_clamp(cur_x + OFFSET_COMPENSATE, 10, W-10))
  1000. steps = abs(target_cur_x - cur_x)
  1001. sign = 1 if target_cur_x > cur_x else -1
  1002. for s in range(steps):
  1003. cur_x += sign
  1004. pipe.move(cur_x, cur_y, 0)
  1005. time.sleep(random.uniform(0.005, 0.010))
  1006. print(f" 固定偏移: {OFFSET_COMPENSATE:+.0f}px 分{steps}步 -> x={cur_x}")
  1007. elif MICRO_MODE == "visual":
  1008. fallback_x = cur_x; prev_pl = None
  1009. for attempt in range(3):
  1010. time.sleep(0.3)
  1011. check = d.screenshot(format="opencv")
  1012. cv2.imwrite(os.path.join(ALIGN_DIR, f"align_{attempt}.png"), check)
  1013. try: pl, _ = detect_captcha_left_edge(check)
  1014. except Exception: break
  1015. diff = pl - gap
  1016. marked = check.copy()
  1017. cv2.line(marked, (pl, 0), (pl, marked.shape[0]), (0, 255, 0), 3)
  1018. cv2.line(marked, (gap, 0), (gap, marked.shape[0]), (0, 0, 255), 3)
  1019. cv2.imwrite(os.path.join(ALIGN_DIR, f"align_{attempt}_marked.png"), marked)
  1020. if abs(diff) <= 5: break
  1021. if prev_pl is not None and pl == prev_pl:
  1022. cur_x = int(_clamp(fallback_x,10,W-10)); pipe.move(cur_x,cur_y,0); break
  1023. prev_pl = pl
  1024. cur_x = int(_clamp(cur_x-diff,10,W-10)); pipe.move(cur_x,cur_y,0); time.sleep(0.05)
  1025. # MICRO_MODE=none has already obtained a fresh confirmation frame above;
  1026. # avoid another synchronous screenshot before releasing the finger.
  1027. if MICRO_MODE != "none" or aligned_img is None:
  1028. aligned_img = d.screenshot(format="opencv")
  1029. phase_timing["alignment_ms"] = (time.perf_counter() - alignment_started) * 1000
  1030. phase_timing["alignment_corrections"] = correction_count
  1031. pipe_diag = _print_touchpipe_diagnostics(pipe)
  1032. phase_timing["touchpipe"] = pipe_diag
  1033. pipe.close(); d.touch.up(int(cur_x), int(cur_y))
  1034. phase_timing["touch_total_ms"] = (time.perf_counter() - touch_started) * 1000
  1035. time.sleep(2)
  1036. # 验证
  1037. final = d.screenshot(format="opencv"); check_r = ocr_eng(final)
  1038. passed = True
  1039. if check_r and check_r[0]:
  1040. if any("拖动滑块" in it[1] or "请按住滑块" in it[1] or "安全验证" in it[1] for it in check_r[0]): passed = False
  1041. if not passed:
  1042. time.sleep(2); final = d.screenshot(format="opencv"); check_r = ocr_eng(final)
  1043. passed = True
  1044. if check_r and check_r[0]:
  1045. if any("拖动滑块" in it[1] or "请按住滑块" in it[1] or "安全验证" in it[1] for it in check_r[0]): passed = False
  1046. print(f" 结果: {'OK' if passed else 'FAIL'}")
  1047. # 图片和 JSON 使用实际发送点;被调度器跳过的计划点不再画进轨迹。
  1048. all_track = [(item["x"], item["y"]) for item in track_pts]
  1049. rd = SUCCESS_DIR if passed else FAILURE_DIR
  1050. now = time.localtime()
  1051. dev_id = getattr(d, 'serial', getattr(d, '_serial', 'unknown'))
  1052. prefix = f"{dev_id}_{now.tm_year:04d}{now.tm_mon:02d}{now.tm_mday:02d}_{now.tm_hour:02d}{now.tm_min:02d}{now.tm_sec:02d}"
  1053. save_track_image(all_track, os.path.join(rd, f"{prefix}_track.png"))
  1054. if aligned_img is not None:
  1055. b_img = aligned_img.copy()
  1056. for i in range(1, len(all_track)):
  1057. cv2.line(b_img, all_track[i-1], all_track[i], (0, 200, 200), 2)
  1058. cv2.circle(b_img, all_track[0], 6, (0, 255, 0), -1)
  1059. cv2.circle(b_img, all_track[-1], 6, (0, 0, 255), -1)
  1060. cv2.imwrite(os.path.join(rd, f"{prefix}_b.png"), b_img)
  1061. tj = {"device": dev_id, "screen": {"w": W, "h": H},
  1062. "time": time.strftime("%Y-%m-%d %H:%M:%S", now),
  1063. "total_points": len(track_pts),
  1064. "duration_ms": track_pts[-1]["rel_ms"] if track_pts else 0,
  1065. "phase_timing": phase_timing,
  1066. "start": {"x": track_pts[0]["x"], "y": track_pts[0]["y"]},
  1067. "turn": {"x": turn_x, "y": turn_y},
  1068. "end": {"x": track_pts[-1]["x"], "y": track_pts[-1]["y"]},
  1069. "dx": track_pts[-1]["x"]-track_pts[0]["x"],
  1070. "dy": track_pts[-1]["y"]-track_pts[0]["y"],
  1071. "passed": passed, "gap": gap,
  1072. "points": track_pts}
  1073. with open(os.path.join(rd, f"{prefix}_track.json"), 'w', encoding='utf-8') as fp:
  1074. json.dump(tj, fp, ensure_ascii=False)
  1075. return passed
  1076. if __name__ == "__main__":
  1077. DEVICE = "SK4T6XZH4PEUOZ99"
  1078. d = u2.connect(DEVICE)
  1079. print(f"设备: {DEVICE}")
  1080. solve_slider(d)