tmp_captcha_test7.py 68 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487
  1. # -*- coding: utf-8 -*-
  2. """test7 — test6 的A/B试验版: 落点补偿 -3px(实测落点系统性偏右2~6px, 甜蜜点0~+4)。
  3. 改动: ①OFFSET_COMPENSATE=-2(实测容忍窗不对称: 左仅~5px/右~8px, 0±2最稳) ②归档独立 ③离群标记 ④右滑提速。其余与test6一致。
  4. 2026-09-22 改(guiji_new 19条真人轨迹实测校准): ⑤右段换真人速度模板(爆发起步→长弧刹车, 原为匀速) ⑥Y漂移校准: 右段改向下(-2%~+10%), 折回改单一向下0.20~0.38(原±28px比真人小6倍) ⑦确认阶段到位后静默不重发 ⑧释放前静默停顿40~120ms ⑨折回改为真人事件率时间步进采样(dt 4.0~7.5ms重尾, 巡航段偶发30~80ms微犹豫, 贴入缺口段连续不停顿)——实测尾段是一像素一像素磨进缺口的, 旧的100点均匀采样会在贴入段变成4~5px一跳 ⑩初始按压收窄到60~120ms(实测中位75ms) ⑪发送层: Windows定时器提升到1ms+忙等, 落后时整体后移时间表而非丢点——丢点会让轨迹出现"卡顿后飞过去"的瞬移点。⑫折回速度剖面重写(逐条实测速度曲线): 巡航到距缺口50~90px → 缓降到~25%峰值 → 最后12~25px急剧降到爬行速度, 再用~10px极慢磨进缺口——真人样本"100px:1.05 → 20px:0.23 → 10px:0.03 → 5px:0.03"; 前两版(长弧衰减 / 巡航到10px才刹车)都与实测不符。⑬释放前慢速微调: 用幕布左边缘量出API gap与真实缺口的剩余距离(≤30px), 1px/30~90ms慢慢挪过去再释放。"""
  5. import sys, os, json, time, random, math, threading
  6. import uiautomator2 as u2
  7. from uiautomator2.core import AdbHTTPConnection
  8. import cv2, numpy as np, base64, requests
  9. from rapidocr_onnxruntime import RapidOCR
  10. os.environ["PYTHONIOENCODING"] = "utf-8"
  11. if sys.platform == "win32":
  12. # Windows 默认定时器精度约15.6ms: sleep(4~7ms)会随机超睡, 导致发送
  13. # 卡顿和跳点(轨迹飞点)。提升到1ms后真人事件间隔才能稳定发出。
  14. try:
  15. import ctypes
  16. ctypes.windll.winmm.timeBeginPeriod(1)
  17. except Exception:
  18. pass
  19. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # 项目根(tbsg): detect_slider_button 等
  20. from detect_slider_button import detect_slider_button
  21. from detect_captcha_edge import detect_captcha_left_edge
  22. ocr_eng = RapidOCR()
  23. TOKEN = "1nDVocTE2mJ0yLEYb2sZJ5uUY2VIEoGTkIpW44X7Kgk"
  24. JFBYM_URL = "http://api.jfbym.com/api/YmServer/customApi"
  25. BASE = os.path.dirname(os.path.abspath(__file__))
  26. IMG_ROOT = os.path.join(BASE, "image", "test7_image")
  27. OUT = os.path.join(IMG_ROOT, "d")
  28. ALIGN_DIR = os.path.join(IMG_ROOT, "align")
  29. # 按天分类:success/failure 下按日期建子目录(如 success/2026-08-19/xxx.png)
  30. _TODAY = time.strftime("%Y-%m-%d")
  31. SUCCESS_DIR = os.path.join(IMG_ROOT, "success", _TODAY)
  32. FAILURE_DIR = os.path.join(IMG_ROOT, "failure", _TODAY)
  33. os.makedirs(OUT, exist_ok=True)
  34. os.makedirs(ALIGN_DIR, exist_ok=True)
  35. os.makedirs(SUCCESS_DIR, exist_ok=True)
  36. os.makedirs(FAILURE_DIR, exist_ok=True)
  37. OFFSET_COMPENSATE = 0
  38. MICRO_MODE = "none"
  39. # 真人速度模板的慢速尾段原本约占最后 10% 路程。运行时把这段压缩到
  40. # 最后 3%~5%,让前段继续快速靠近,离缺口很近后才明显减速。
  41. RETURN_BRAKE_SOURCE_START = 0.90
  42. RETURN_BRAKE_START_RANGE = (0.86, 0.93)
  43. # 折回主段使用固定的快速节奏,不再直接照搬某一条可能很慢的真人模板。
  44. # 采样间隔保持在触摸事件可稳定消费的范围,距离越长只增加点数,不拉长点间隔。
  45. RETURN_SAMPLE_INTERVAL_RANGE = (0.0042, 0.0052)
  46. RETURN_BRAKE_TIME_RANGE = (0.75, 0.88) # (保留兼容, 剖面模式下未用)
  47. # ── 真人折回速度剖面(2026-09-22 13条轨迹实测): 按路程位置(10段)的速度区间 px/ms ──
  48. # 0%=折返点旁 100%=缺口旁; 形状 = 起步→峰值→长弧渐进减速→爬行入缺口
  49. RETURN_SPEED_PROFILE = (
  50. (0.24, 1.68), (0.08, 3.56), (0.13, 3.98), (0.15, 3.69), (0.15, 3.07),
  51. (0.08, 2.17), (0.07, 1.62), (0.09, 1.51), (0.04, 1.34), (0.02, 1.17),
  52. )
  53. RETURN_CURVE_LIMIT_1220 = 42
  54. # 2026-09-22 19条实测: 折回Y漂移全部向下(比值0.19~0.39, 绝对值90~239px),
  55. # 旧±28px上限比真人小6倍且无方向偏好; 上限按实测最大值239px放宽。
  56. RETURN_Y_CORRIDOR_1220 = 280
  57. RETURN_END_DRIFT_LIMIT_1220 = 240
  58. # 方案一参数:按真人样本的量级修正点密度与阶段时长。
  59. RIGHT_SAMPLE_INTERVAL_RANGE = (0.0038, 0.0052)
  60. RIGHT_POINT_LIMITS = (60, 110)
  61. # 右滑移动本身按 test5 的真人节奏控制;到最右端后的幕布展开等待
  62. # 由 RIGHT_SETTLE_RANGE 单独负责,不计入右滑阶段。
  63. RIGHT_SPEED_RANGE = (0.45, 1.55) # 右滑速度 px/ms(13条真人轨迹 0.40~1.66, 覆盖全部样本)
  64. TRACK_SKIP_LATE_S = 0.010
  65. # 真人样本的右端折返点集中在约 1023~1109 px;不要每次都固定在 1190。
  66. # 下限略高于滑块可确认的右端,避免随机到太靠左导致幕布未完全展开。
  67. RIGHT_END_RANGE_1220 = (1060, 1105)
  68. # 真人右滑Y漂移: 2026-09-22 19条实测均值+3.6%向下(范围-2.3%~+11.7%),
  69. # 旧数据"轻微向上收尾"的结论作废, 方向改为向下为主。
  70. RIGHT_Y_DRIFT_RATIO_RANGE = (-0.02, 0.10)
  71. # 真人右段时长实测444~991ms(19条, 800~930px跨度), 模板时长按距离缩放后钳位在此区间
  72. RIGHT_TEMPLATE_DURATION_RANGE = (0.30, 1.20)
  73. # TouchPipe 异步发送后,至少给幕布动画和设备事件队列留出稳定时间。
  74. RIGHT_SETTLE_RANGE = (0.55, 0.85)
  75. RIGHT_READY_TIMEOUT_S = 3.50
  76. RETURN_READY_TIMEOUT_S = 1.80
  77. SLIDER_POSITION_TOLERANCE = 12
  78. # 真人轨迹模板目录。模板只使用归一化后的形状,不直接复用原始触摸坐标,
  79. # 因此不会把旧设备的绝对坐标带到当前屏幕。
  80. GUIJI_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "guiji_new")
  81. _GUIJI_TEMPLATES = None
  82. def _clamp(v, lo, hi):
  83. return max(lo, min(v, hi))
  84. def _detect_slider_center(image):
  85. """从 OpenCV 截图中检测橙色滑块中心。"""
  86. if image is None or getattr(image, "ndim", 0) != 3:
  87. return None
  88. rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
  89. hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
  90. mask = cv2.inRange(hsv, np.array([8, 230, 230]), np.array([22, 255, 255]))
  91. h, _ = mask.shape[:2]
  92. mask[:int(h * 0.55), :] = 0
  93. kernel = np.ones((5, 5), np.uint8)
  94. mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
  95. mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
  96. contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  97. candidates = []
  98. for cnt in contours:
  99. x, y, bw, bh = cv2.boundingRect(cnt)
  100. area = bw * bh
  101. if area < 300 or bw < 30 or bh < 30:
  102. continue
  103. aspect = min(bw, bh) / max(bw, bh)
  104. if aspect < 0.5:
  105. continue
  106. candidates.append((area * aspect, x, bw))
  107. if not candidates:
  108. return None
  109. _, x, bw = max(candidates, key=lambda item: item[0])
  110. return x + bw / 2.0
  111. def _wait_for_slider_position(pipe, device, x, y, expected_center=None,
  112. min_center=None, timeout_s=3.0, label="",
  113. track_pts=None, t0=None, phase="confirm",
  114. stable_required=2):
  115. """重复发送终点并轮询真实滑块位置,避免截图/松手早于异步事件落地。
  116. 真人折返点/缺口旁的停顿期间完全静默(无任何触摸事件),因此首次
  117. 确认到位后停止常规补发;之后仅在检测到明显回退时补发一次。"""
  118. started = time.perf_counter()
  119. stable = 0
  120. last_center = None
  121. last_frame = None
  122. confirmed = False # 首次确认到位后静默
  123. last_push = 0.0
  124. def _needs_push(center):
  125. if not confirmed:
  126. return True
  127. if center is None:
  128. return False
  129. if expected_center is not None:
  130. return abs(center - expected_center) > SLIDER_POSITION_TOLERANCE * 2
  131. if min_center is not None:
  132. return center < min_center - SLIDER_POSITION_TOLERANCE
  133. return False
  134. while time.perf_counter() - started < timeout_s:
  135. send_ms = 0.0
  136. if _needs_push(last_center) and time.perf_counter() - last_push >= 0.08:
  137. last_push = time.perf_counter()
  138. try:
  139. send_ms = pipe.move(int(x), int(y), 0)
  140. except Exception:
  141. pass
  142. if track_pts is not None:
  143. track_pts.append({
  144. "x": int(x), "y": int(y), "pressure": 0,
  145. "phase": phase, "confirm": True,
  146. "scheduled_ms": None, "lag_ms": None,
  147. "send_ms": send_ms,
  148. "rel_ms": ((time.perf_counter() - t0) * 1000
  149. if t0 is not None else None),
  150. })
  151. time.sleep(0.06)
  152. try:
  153. frame = device.screenshot(format="opencv")
  154. except Exception:
  155. frame = None
  156. if frame is not None:
  157. last_frame = frame
  158. center = _detect_slider_center(frame)
  159. if center is not None:
  160. last_center = center
  161. if expected_center is not None:
  162. ok = abs(center - expected_center) <= SLIDER_POSITION_TOLERANCE
  163. elif min_center is not None:
  164. ok = center >= min_center
  165. else:
  166. ok = True
  167. if ok:
  168. confirmed = True
  169. stable = stable + 1 if ok else 0
  170. if stable >= stable_required:
  171. elapsed = (time.perf_counter() - started) * 1000
  172. return last_frame, last_center, elapsed, True
  173. time.sleep(0.06)
  174. elapsed = (time.perf_counter() - started) * 1000
  175. if label:
  176. actual = "未检测到" if last_center is None else f"{last_center:.0f}"
  177. target = (f"{expected_center:.0f}" if expected_center is not None
  178. else f">={min_center:.0f}" if min_center is not None else "任意")
  179. print(f" {label}确认超时: actual={actual}, target={target}")
  180. return last_frame, last_center, elapsed, False
  181. def _dedupe_track(points):
  182. """移除取整后相邻的重复坐标,避免末端连续发送同一个点。"""
  183. result = []
  184. for x, y in points:
  185. point = (int(x), int(y))
  186. if not result or point != result[-1]:
  187. result.append(point)
  188. return result
  189. def _subdivide_track(points, schedule, max_step_px=16.0, min_dt_s=0.007):
  190. """大跨步拆成多步: 真人事件间隔~4.4ms, 高速段单步只有15~25px;
  191. 主机采样间隔10~14ms会让峰值段一步40px+, 视觉上"加速过猛"。
  192. 只在步长>max_step_px且间隔>=min_dt_s时拆分, 时间表同步对半插值。"""
  193. if schedule is None or len(points) < 2 or len(schedule) != len(points):
  194. return points, schedule
  195. new_pts = [points[0]]
  196. new_sched = [schedule[0]]
  197. for i in range(1, len(points)):
  198. x0, y0 = points[i - 1]
  199. x1, y1 = points[i]
  200. t0, t1 = schedule[i - 1], schedule[i]
  201. step = math.hypot(x1 - x0, y1 - y0)
  202. if step > max_step_px and (t1 - t0) >= min_dt_s:
  203. n = int(math.ceil(step / max_step_px))
  204. # 子步间隔不低于3.5ms, 超出主机稳定发送能力
  205. n = min(n, max(1, int((t1 - t0) / 0.0035)))
  206. for k in range(1, n):
  207. f = k / n
  208. new_pts.append((int(round(x0 + (x1 - x0) * f)),
  209. int(round(y0 + (y1 - y0) * f))))
  210. new_sched.append(t0 + (t1 - t0) * f)
  211. new_pts.append(points[i])
  212. new_sched.append(schedule[i])
  213. return new_pts, new_sched
  214. def _pressure_curve(i, n, phase):
  215. # uiautomator2 injectInputEvent 的第四参数是 metaState,不是压力;保持为 0。
  216. return 0
  217. def _choose_right_end_x(start_x, screen_w):
  218. """返回接近真人分布、且仍能完全揭开幕布的右侧折返点。"""
  219. scale = screen_w / 1220.0
  220. min_travel = 800 * scale
  221. lo = int(round(RIGHT_END_RANGE_1220[0] * scale))
  222. hi = int(round(RIGHT_END_RANGE_1220[1] * scale))
  223. sampled = random.uniform(lo, hi)
  224. return int(round(_clamp(sampled, start_x + min_travel, screen_w - 12)))
  225. def _return_duration(distance, scale):
  226. """折回总时长(人速校准): 5条真人轨迹折回速度 0.07~0.89 px/ms (70~890 px/s)。
  227. 主段巡航 + 0.15~0.35s 收尾, 上限2.6s(含末端爬行微调)。"""
  228. speed = random.uniform(400.0, 900.0) * max(scale, 0.8) # px/s(实测: 慢于400px/s失败率61%, 快于800仅26%)
  229. return _clamp(distance / speed + random.uniform(0.15, 0.30), 0.35, 2.6)
  230. def _timing_schedule(count, duration_s, phase):
  231. """生成总时长固定、点间隔轻微相关的发送时间表。"""
  232. if count <= 1:
  233. return [0.0]
  234. phase_shift = random.uniform(0, math.tau)
  235. weights = []
  236. for i in range(count - 1):
  237. t = i / max(1, count - 2)
  238. correlated = 0.10 * math.sin(math.tau * (1.2 * t) + phase_shift)
  239. jitter = random.uniform(-0.05, 0.05)
  240. if phase == 'return' and t > 0.75:
  241. correlated += 0.08 * (t - 0.75) / 0.25
  242. weights.append(max(0.70, 1.0 + correlated + jitter))
  243. total = sum(weights)
  244. elapsed = 0.0
  245. schedule = [0.0]
  246. for weight in weights:
  247. elapsed += duration_s * weight / total
  248. schedule.append(elapsed)
  249. schedule[-1] = duration_s
  250. return schedule
  251. def _emit_track(pipe, track, phase, track_pts, t0, duration_s,
  252. schedule_override=None):
  253. """按绝对时间表发送。落后时把剩余时间表整体后移而不是丢点:
  254. 丢点会让手指瞬移(轨迹上出现飞点), 整体后移只表现为轻微变慢,
  255. 点与点之间的真实间隔保持不变。终点始终发送。"""
  256. if not track:
  257. return 0.0, {
  258. "planned_points": 0, "sent_points": 0, "skipped_points": 0,
  259. "resyncs": 0, "shifted_ms": 0.0,
  260. "target_ms": duration_s * 1000, "elapsed_ms": 0.0,
  261. "lag_p95_ms": 0.0, "lag_max_ms": 0.0,
  262. "send_p95_ms": 0.0, "send_max_ms": 0.0,
  263. }
  264. started = time.perf_counter()
  265. if schedule_override is not None and len(schedule_override) == len(track):
  266. schedule = list(schedule_override)
  267. schedule[0] = 0.0
  268. schedule[-1] = duration_s
  269. else:
  270. schedule = _timing_schedule(len(track), duration_s, phase)
  271. lag_samples = []
  272. send_samples = []
  273. skipped = 0
  274. resyncs = 0
  275. shifted_ms = 0.0
  276. for i, (x, y) in enumerate(track):
  277. deadline = started + schedule[i]
  278. wait_s = deadline - time.perf_counter()
  279. if wait_s > 0.002:
  280. time.sleep(wait_s - 0.001) # Windows定时器已提升到1ms, 剩余用忙等
  281. while time.perf_counter() < deadline:
  282. pass
  283. lag_s = max(0.0, time.perf_counter() - deadline)
  284. # 落后超过阈值: 不丢中间点, 把剩余时间表整体后移"全部"落后量。
  285. # 只移超出阈值的部分会残留~10ms落后, 之后每个点都会再次触发
  286. # 重同步(级联放大, 一次卡顿变几十次)。最后一点不做处理。
  287. if i < len(track) - 1 and lag_s > TRACK_SKIP_LATE_S:
  288. shift = lag_s
  289. for j in range(i + 1, len(schedule)):
  290. schedule[j] += shift
  291. resyncs += 1
  292. shifted_ms += shift * 1000
  293. pressure = _pressure_curve(i, len(track), phase)
  294. send_ms = pipe.move(x, y, pressure)
  295. lag_ms = max(0.0, (time.perf_counter() - deadline) * 1000)
  296. lag_samples.append(lag_ms)
  297. send_samples.append(send_ms)
  298. track_pts.append({"x": x, "y": y, "pressure": pressure,
  299. "phase": phase,
  300. "scheduled_ms": schedule[i] * 1000,
  301. "lag_ms": lag_ms, "send_ms": send_ms,
  302. "rel_ms": (time.perf_counter()-t0)*1000})
  303. elapsed_s = time.perf_counter() - started
  304. def percentile(values, ratio):
  305. if not values:
  306. return 0.0
  307. ordered = sorted(values)
  308. return ordered[int(round((len(ordered) - 1) * ratio))]
  309. stats = {
  310. "planned_points": len(track),
  311. "sent_points": len(track) - skipped,
  312. "skipped_points": skipped,
  313. "resyncs": resyncs,
  314. "shifted_ms": shifted_ms,
  315. "target_ms": duration_s * 1000,
  316. "elapsed_ms": elapsed_s * 1000,
  317. "lag_p95_ms": percentile(lag_samples, 0.95),
  318. "lag_max_ms": max(lag_samples, default=0.0),
  319. "send_p95_ms": percentile(send_samples, 0.95),
  320. "send_max_ms": max(send_samples, default=0.0),
  321. }
  322. return elapsed_s, stats
  323. class TouchPipe:
  324. def __init__(self, dev):
  325. self._dev = dev; self._conn = None; self._sock = None
  326. self._stop = threading.Event(); self._drainer = None
  327. self._lock = threading.Lock(); self._fallback = False
  328. self._open_ok = False; self._open_error = None
  329. self._fallback_reason = None; self._last_error = None
  330. self._send_errors = 0; self._move_errors = 0
  331. self._pipe_move_ms = []; self._fallback_move_ms = []
  332. def open(self):
  333. try:
  334. self._conn = AdbHTTPConnection(self._dev.adb_device, port=9008)
  335. self._conn.timeout = 15; self._conn.connect()
  336. self._sock = self._conn.sock; self._sock.settimeout(0.5)
  337. self._drainer = threading.Thread(target=self._drain, daemon=True)
  338. self._drainer.start()
  339. self._open_ok = True
  340. except Exception as exc:
  341. self._fallback = True
  342. self._fallback_reason = "open_error"
  343. self._open_error = repr(exc)
  344. self._last_error = repr(exc)
  345. return self
  346. def _drain(self):
  347. # select 节流: 手机回包到达时才读, 避免热循环 recv 每秒唤醒上百次
  348. # 与发送循环抢 GIL(会放大发送循环的调度卡顿)。
  349. import select
  350. while not self._stop.is_set():
  351. try:
  352. ready, _, _ = select.select([self._sock], [], [], 0.05)
  353. if ready and not self._sock.recv(65536): break
  354. except Exception: continue
  355. def move(self, x, y, pressure=0):
  356. if self._fallback:
  357. started = time.perf_counter()
  358. try:
  359. self._dev.touch.move(int(x), int(y))
  360. except Exception as exc:
  361. self._move_errors += 1
  362. self._last_error = repr(exc)
  363. elapsed_ms = (time.perf_counter() - started) * 1000
  364. self._fallback_move_ms.append(elapsed_ms)
  365. return elapsed_ms
  366. started = time.perf_counter()
  367. try:
  368. with self._lock:
  369. self._sock.sendall(self._req(x, y, pressure))
  370. elapsed_ms = (time.perf_counter() - started) * 1000
  371. self._pipe_move_ms.append(elapsed_ms)
  372. return elapsed_ms
  373. except Exception as exc:
  374. self._send_errors += 1
  375. self._last_error = repr(exc)
  376. self._fallback = True
  377. self._fallback_reason = "send_error"
  378. return self.move(x, y, pressure)
  379. def _req(self, x, y, pressure):
  380. body = json.dumps({"jsonrpc":"2.0","id":1,"method":"injectInputEvent",
  381. "params":[2,int(x),int(y),0]}).encode()
  382. return (f"POST /jsonrpc/0 HTTP/1.1\r\nHost: localhost\r\n"
  383. f"User-Agent: u2\r\nAccept-Encoding: \r\n"
  384. f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n"
  385. f"Connection: keep-alive\r\n\r\n").encode() + body
  386. def close(self):
  387. self._stop.set()
  388. if self._drainer: self._drainer.join(timeout=1)
  389. try:
  390. if self._sock: self._sock.close()
  391. except Exception: pass
  392. def diagnostics(self):
  393. def percentile(values, ratio):
  394. if not values:
  395. return 0.0
  396. ordered = sorted(values)
  397. return ordered[int(round((len(ordered) - 1) * ratio))]
  398. return {
  399. "open_ok": self._open_ok,
  400. "fallback_used": self._fallback,
  401. "fallback_reason": self._fallback_reason,
  402. "open_error": self._open_error,
  403. "last_error": self._last_error,
  404. "send_errors": self._send_errors,
  405. "move_errors": self._move_errors,
  406. "pipe_moves": len(self._pipe_move_ms),
  407. "fallback_moves": len(self._fallback_move_ms),
  408. "pipe_send_p50_ms": percentile(self._pipe_move_ms, 0.50),
  409. "pipe_send_p95_ms": percentile(self._pipe_move_ms, 0.95),
  410. "pipe_send_max_ms": max(self._pipe_move_ms, default=0.0),
  411. "fallback_move_p95_ms": percentile(self._fallback_move_ms, 0.95),
  412. "fallback_move_max_ms": max(self._fallback_move_ms, default=0.0),
  413. }
  414. def _print_touchpipe_diagnostics(pipe):
  415. diag = pipe.diagnostics()
  416. print(f" TouchPipe: open={'OK' if diag['open_ok'] else 'FAIL'} "
  417. f"fallback={diag['fallback_used']} errors={diag['send_errors']} "
  418. f"send95={diag['pipe_send_p95_ms']:.2f}ms "
  419. f"sendMax={diag['pipe_send_max_ms']:.2f}ms")
  420. if diag["last_error"]:
  421. print(f" TouchPipe最后错误: {diag['last_error']}")
  422. return diag
  423. def _normalize_phase_template(points, phase):
  424. """Convert one recorded guiji phase into a device-independent curve.
  425. u is horizontal progress (0..1); residual is the signed distance from the
  426. straight endpoint chord divided by the horizontal span. This keeps the
  427. shape reusable on a different screen size and avoids copying raw touch
  428. coordinates from the recording device.
  429. """
  430. if not points or len(points) < 12:
  431. return None
  432. xy = [(float(p.get("x", 0)), float(p.get("y", 0))) for p in points]
  433. if phase == "right":
  434. split = max(range(len(xy)), key=lambda i: xy[i][0])
  435. xy = xy[:split + 1]
  436. x0, x1 = xy[0][0], xy[-1][0]
  437. span = x1 - x0
  438. # 门槛单位=屏幕像素(与运行时选桶一致)。老录制器写原始触摸单位(1px≈16单位),
  439. # 阈值1000实际只有62px; 新录制器x直接是像素, 真人右滑实际跨度~850~930px
  440. if span < 300:
  441. return None
  442. raw_u = [(x - x0) / span for x, _ in xy]
  443. else:
  444. split = max(range(len(xy)), key=lambda i: xy[i][0])
  445. xy = xy[split:]
  446. if len(xy) < 12:
  447. return None
  448. x0, x1 = xy[0][0], xy[-1][0]
  449. span = x0 - x1
  450. # 像素门槛: 真人折回跨度实测146~796px, 100px以下才算退化数据
  451. if span < 100:
  452. return None
  453. raw_u = [(x0 - x) / span for x, _ in xy]
  454. # End-of-phase recordings can contain a few backtracking pixels. Preserve
  455. # the broad human curve but make the interpolation coordinate monotonic.
  456. raw_u = np.maximum.accumulate(np.asarray(raw_u, dtype=np.float64))
  457. raw_u = np.clip(raw_u, 0.0, 1.0)
  458. unique_u, unique_idx = np.unique(raw_u, return_index=True)
  459. if len(unique_u) < 8:
  460. return None
  461. yy = np.asarray([xy[i][1] for i in unique_idx], dtype=np.float64)
  462. sample_u = np.linspace(0.0, 1.0, 81)
  463. sample_y = np.interp(sample_u, unique_u, yy)
  464. chord_y = sample_y[0] + (sample_y[-1] - sample_y[0]) * sample_u
  465. residual = (sample_y - chord_y) / span
  466. residual[0] = 0.0
  467. residual[-1] = 0.0
  468. # A few recordings contain unusually large endpoint excursions. They are
  469. # valid data, but clipping keeps one outlier from producing an unsafe path.
  470. residual = np.clip(residual, -0.18, 0.18)
  471. return {
  472. "u": sample_u.tolist(),
  473. "residual": residual.tolist(),
  474. "curvature": float(np.max(np.abs(residual))),
  475. "raw_points": len(xy),
  476. }
  477. def _normalize_velocity_template(points, phase):
  478. """Extract horizontal progress as a function of elapsed phase time.
  479. phase='right' uses touch-down -> turn point; 'return' uses turn
  480. point -> release. Stationary holds (initial press hold, turn-point
  481. hold, endpoint plateau) are trimmed so the template only contains
  482. real motion; micro-hesitations in between stay in the raw arrays.
  483. """
  484. if not points or len(points) < 20:
  485. return None
  486. turn_idx = max(range(len(points)), key=lambda i: float(points[i].get("x", 0)))
  487. if phase == "right":
  488. seg = points[:turn_idx + 1]
  489. min_span = 300.0
  490. sign = 1.0
  491. else:
  492. seg = points[turn_idx:]
  493. min_span = 100.0
  494. sign = -1.0
  495. x0 = float(seg[0].get("x", 0))
  496. x1 = float(seg[-1].get("x", 0))
  497. full_span = (x1 - x0) * sign
  498. if full_span < min_span:
  499. return None
  500. # Skip the stationary start: the initial press hold on the right phase
  501. # and the hold at the turn point on the return phase are pauses, not
  502. # motion, and are emitted separately by the runtime.
  503. threshold = max(10.0, full_span * 0.01)
  504. start_idx = None
  505. for i in range(1, len(seg)):
  506. if (float(seg[i].get("x", 0)) - x0) * sign >= threshold:
  507. start_idx = i
  508. break
  509. if start_idx is None or len(seg) - start_idx < 12:
  510. return None
  511. motion = seg[start_idx:]
  512. mx0 = float(motion[0].get("x", 0))
  513. mx1 = float(motion[-1].get("x", 0))
  514. span = (mx1 - mx0) * sign
  515. t0 = float(motion[0].get("rel_ms", 0.0))
  516. t1 = float(motion[-1].get("rel_ms", 0.0))
  517. duration_ms = t1 - t0
  518. if span < 100 or duration_ms < 100:
  519. return None
  520. raw_t = np.asarray([
  521. (float(p.get("rel_ms", 0.0)) - t0) / duration_ms for p in motion
  522. ], dtype=np.float64)
  523. raw_u = np.asarray([
  524. (float(p.get("x", 0)) - mx0) * sign / span for p in motion
  525. ], dtype=np.float64)
  526. raw_t = np.clip(raw_t, 0.0, 1.0)
  527. raw_u = np.maximum.accumulate(np.clip(raw_u, 0.0, 1.0))
  528. unique_t, unique_idx = np.unique(raw_t, return_index=True)
  529. if len(unique_t) < 8:
  530. return None
  531. unique_u = raw_u[unique_idx]
  532. # The recorder often keeps sending the final coordinate after the finger
  533. # has already stopped. That endpoint plateau is a hold phase, not real
  534. # motion, so trim it from the velocity template.
  535. end_idx = next((i for i, value in enumerate(unique_u)
  536. if value >= 0.999), len(unique_u) - 1)
  537. motion_end_t = max(float(unique_t[end_idx]), 1e-6)
  538. event_t = unique_t[:end_idx + 1] / motion_end_t
  539. event_progress = unique_u[:end_idx + 1]
  540. event_t[0] = 0.0
  541. event_t[-1] = 1.0
  542. event_progress[0] = 0.0
  543. event_progress[-1] = 1.0
  544. sample_t = np.linspace(0.0, 1.0, 101)
  545. sample_u = np.interp(sample_t, event_t, event_progress)
  546. sample_u[0] = 0.0
  547. sample_u[-1] = 1.0
  548. return {
  549. "phase": phase,
  550. "t": sample_t.tolist(),
  551. "progress": sample_u.tolist(),
  552. "event_t": event_t.tolist(),
  553. "event_progress": event_progress.tolist(),
  554. "duration_ms": duration_ms * motion_end_t,
  555. "plateau_ms": duration_ms * (1.0 - motion_end_t),
  556. "span": span,
  557. "raw_points": len(event_t),
  558. }
  559. def _load_guiji_templates():
  560. global _GUIJI_TEMPLATES
  561. if _GUIJI_TEMPLATES is not None:
  562. return _GUIJI_TEMPLATES
  563. templates = {
  564. "right": [], "short": [], "medium": [], "long": [],
  565. "velocity": {"short": [], "medium": [], "long": []},
  566. "velocity_right": [],
  567. }
  568. try:
  569. names = sorted(n for n in os.listdir(GUIJI_ROOT) if n.lower().endswith(".json"))
  570. except OSError:
  571. names = []
  572. for name in names:
  573. try:
  574. with open(os.path.join(GUIJI_ROOT, name), "r", encoding="utf-8") as fh:
  575. record = json.load(fh)
  576. points = record.get("points") or []
  577. right = _normalize_phase_template(points, "right")
  578. ret = _normalize_phase_template(points, "return")
  579. velocity = _normalize_velocity_template(points, "return")
  580. right_velocity = _normalize_velocity_template(points, "right")
  581. if right:
  582. templates["right"].append(right)
  583. if ret:
  584. span = max(float(points[i]["x"]) for i in range(len(points))) - float(points[-1]["x"])
  585. # 分桶单位=像素, 与运行时 _build_human_return_track 的450/600px分界一致
  586. if span < 450:
  587. key = "short"
  588. elif span < 600:
  589. key = "medium"
  590. else:
  591. key = "long"
  592. templates[key].append(ret)
  593. if velocity:
  594. templates["velocity"][key].append(velocity)
  595. if right_velocity:
  596. templates["velocity_right"].append(right_velocity)
  597. except (OSError, ValueError, KeyError, TypeError):
  598. continue
  599. # The medium bucket has few observations, so use all return templates as a
  600. # safe fallback instead of inventing an unrelated synthetic curve.
  601. all_returns = templates["short"] + templates["medium"] + templates["long"]
  602. for key in ("short", "medium", "long"):
  603. if not templates[key]:
  604. templates[key] = all_returns[:]
  605. all_velocity = (templates["velocity"]["short"]
  606. + templates["velocity"]["medium"]
  607. + templates["velocity"]["long"])
  608. for key in ("short", "medium", "long"):
  609. if not templates["velocity"][key]:
  610. templates["velocity"][key] = all_velocity[:]
  611. _GUIJI_TEMPLATES = templates
  612. print(" 真人模板: right={} short={} medium={} long={} velocity={}/{}/{} velocity_right={}".format(
  613. len(templates["right"]), len(templates["short"]),
  614. len(templates["medium"]), len(templates["long"]),
  615. len(templates["velocity"]["short"]),
  616. len(templates["velocity"]["medium"]),
  617. len(templates["velocity"]["long"]),
  618. len(templates["velocity_right"])))
  619. return templates
  620. def _template_residual(template, t):
  621. if not template:
  622. return 0.0
  623. return float(np.interp(float(t), template["u"], template["residual"]))
  624. def _template_progress(template, t):
  625. if not template:
  626. # Smooth fallback with a short acceleration section and a long braking
  627. # tail. Real templates are available in normal operation.
  628. t = _clamp(float(t), 0.0, 1.0)
  629. knots_t = np.asarray([0.0, 0.08, 0.20, 0.35, 0.55, 0.75, 0.90, 1.0])
  630. knots_u = np.asarray([0.0, 0.07, 0.23, 0.43, 0.63, 0.80, 0.93, 1.0])
  631. return float(np.interp(t, knots_t, knots_u))
  632. return float(np.interp(float(t), template["t"], template["progress"]))
  633. def _compress_return_braking_tail(progress, brake_start):
  634. """把模板最后 10% 的慢速路程压缩到最后 3%~5%,保持时间顺序不变。"""
  635. values = np.asarray(progress, dtype=np.float64).copy()
  636. source = RETURN_BRAKE_SOURCE_START
  637. target = float(_clamp(brake_start, source + 0.01, 0.99))
  638. before = values <= source
  639. values[before] *= target / source
  640. values[~before] = (
  641. target
  642. + (values[~before] - source) * (1.0 - target) / (1.0 - source)
  643. )
  644. values = np.maximum.accumulate(np.clip(values, 0.0, 1.0))
  645. values[0] = 0.0
  646. values[-1] = 1.0
  647. return values
  648. def _build_fast_return_progress(count, brake_start, brake_time):
  649. """前段快速匀速推进,最后一小段才进入明显的减速/微调。"""
  650. if count <= 1:
  651. return np.asarray([0.0]), np.asarray([0.0])
  652. event_t = np.linspace(0.0, 1.0, int(count), dtype=np.float64)
  653. event_u = np.empty_like(event_t)
  654. main = event_t <= brake_time
  655. main_t = np.clip(event_t[main] / max(brake_time, 1e-6), 0.0, 1.0)
  656. # 轻微的自然起步,不制造旧模板那种长时间慢爬。
  657. event_u[main] = brake_start * np.power(main_t, 0.96)
  658. tail_t = np.clip(
  659. (event_t[~main] - brake_time) / max(1.0 - brake_time, 1e-6),
  660. 0.0, 1.0,
  661. )
  662. # 刚进入最后一段时仍有少量位移,随后逐步减小到目标。
  663. event_u[~main] = brake_start + (1.0 - brake_start) * (
  664. 1.0 - np.power(1.0 - tail_t, 2.2)
  665. )
  666. event_u[0] = 0.0
  667. event_u[-1] = 1.0
  668. return event_t, np.maximum.accumulate(np.clip(event_u, 0.0, 1.0))
  669. def _fallback_bow(t, category, amplitude=None):
  670. """Low-frequency fallback with the same distance-dependent curvature."""
  671. ratios = {"short": (0.020, 0.055), "medium": (0.035, 0.075),
  672. "long": (0.050, 0.115)}
  673. lo, hi = ratios[category]
  674. amp = amplitude
  675. if amp is None:
  676. amp = random.uniform(lo, hi) * random.choice((-1.0, 1.0))
  677. # One broad asymmetric bow; no high-frequency wobble.
  678. return amp * math.sin(math.pi * t) * (0.88 + 0.24 * t)
  679. def _build_right_track(start_x, start_y, end_x, point_count,
  680. velocity_template=None, duration_s=None):
  681. """右滑轨迹:连续低频起伏,避免逐点随机造成锯齿。
  682. velocity_template 给定时, 几何进度和发送时刻直接取自真人右段
  683. 速度模板的原始事件(event_progress/event_t): 匀速采样会变成
  684. "爆发起步→长弧刹车"的真人节奏并保留原始微犹豫。返回
  685. (points, schedule); 模板不可用时返回 (points, None)。
  686. """
  687. dist = end_x - start_x
  688. if dist <= 0:
  689. return [(int(start_x), int(start_y))], None
  690. # Use a normalized guiji curve for the broad motion. The old branch below
  691. # is retained as unreachable reference code while the new generator is
  692. # validated against saved images.
  693. templates = _load_guiji_templates().get("right", [])
  694. template = random.choice(templates) if templates else None
  695. template_sign = random.choice((-1.0, 1.0))
  696. template_gain = random.uniform(0.82, 1.08)
  697. fallback_amp = random.uniform(0.020, 0.055) * random.choice((-1.0, 1.0))
  698. # 2026-09-22 实测: 右滑段Y漂移轻微向下(-2%~+12%), 不再生成向上的漂移。
  699. drift = random.uniform(*RIGHT_Y_DRIFT_RATIO_RANGE) * dist
  700. schedule = None
  701. if velocity_template is not None:
  702. points = []
  703. schedule = []
  704. last_keep_t = -1.0
  705. # 保险下限6ms: 右段模板已在7~9ms网格上重采样, 这里只挡异常密集
  706. # 的原始事件(老模板路径), 不再起滤稀作用
  707. min_gap = 0.006 / max(float(duration_s or 1.0), 1e-6)
  708. for t, u in zip(velocity_template["event_t"],
  709. velocity_template["event_progress"]):
  710. if t - last_keep_t < min_gap:
  711. continue
  712. x = start_x + dist * u
  713. residual = (_template_residual(template, u) * template_sign * template_gain
  714. if template else _fallback_bow(u, "short", fallback_amp))
  715. residual = _clamp(residual, -0.06, 0.06)
  716. y = start_y + drift * u + residual * dist
  717. point = (int(round(x)), int(round(y)))
  718. if points and point == points[-1]:
  719. continue
  720. points.append(point)
  721. schedule.append(t * float(duration_s or 1.0))
  722. last_keep_t = t
  723. if len(points) < 12:
  724. velocity_template = None # 退化, 走匀速分支
  725. points = []
  726. schedule = None
  727. if velocity_template is None:
  728. steps = max(1, int(point_count) - 1)
  729. ease_exp = random.uniform(1.65, 2.05)
  730. points = [(int(start_x), int(start_y))]
  731. for i in range(1, steps + 1):
  732. t = i / steps
  733. u = 1.0 - (1.0 - t) ** ease_exp
  734. x = start_x + dist * u
  735. residual = (_template_residual(template, u) * template_sign * template_gain
  736. if template else _fallback_bow(u, "short", fallback_amp))
  737. residual = _clamp(residual, -0.06, 0.06)
  738. y = start_y + drift * u + residual * dist
  739. points.append((int(round(x)), int(round(y))))
  740. points[-1] = (int(end_x), points[-1][1])
  741. points = _dedupe_track(points)
  742. schedule = None
  743. else:
  744. points[-1] = (int(end_x), points[-1][1])
  745. if len(schedule) > 1:
  746. schedule[0] = 0.0
  747. return points, schedule
  748. # Legacy procedural branch kept below for easy rollback during validation.
  749. steps = max(1, int(point_count) - 1)
  750. form = random.choices(
  751. ['flat', 'arch', 'decline', 'slope'],
  752. weights=[0.25, 0.35, 0.20, 0.20],
  753. k=1,
  754. )[0]
  755. drift = random.triangular(-70, 95, 22)
  756. if abs(drift) < 18:
  757. drift = 18 * random.choice([-1, 1])
  758. arch_h = random.triangular(18, 70, 38) * random.choice([-1, 1])
  759. decline_dy = random.triangular(20, 90, 45)
  760. slope_dy = random.triangular(-75, 100, 25)
  761. ease_exp = random.uniform(1.65, 2.15)
  762. wobble_amp = random.uniform(1.5, 5.0)
  763. wobble_cycles = random.uniform(1.0, 2.2)
  764. wobble_phase = random.uniform(0, math.tau)
  765. points = [(int(start_x), int(start_y))]
  766. for i in range(1, steps + 1):
  767. t = i / steps
  768. x = start_x + dist * (1.0 - (1.0 - t) ** ease_exp)
  769. if form == 'flat':
  770. y = start_y + drift * t
  771. elif form == 'arch':
  772. y = start_y + drift * t + arch_h * math.sin(math.pi * t)
  773. elif form == 'decline':
  774. y = start_y + drift * t + abs(arch_h) * math.sin(math.pi * t) + decline_dy * (t ** 2)
  775. else: # slope
  776. y = start_y + slope_dy * t
  777. y += (wobble_amp * math.sin(math.tau * wobble_cycles * t + wobble_phase)
  778. * math.sin(math.pi * t))
  779. points.append((int(round(x)), int(round(y))))
  780. points[-1] = (int(end_x), points[-1][1])
  781. return _dedupe_track(points)
  782. def _build_human_return_track(start_x, start_y, target_x,
  783. duration_s=None, return_schedule=False):
  784. """折返轨迹:按距离调整点密度,并保留轻微的平滑回修。"""
  785. distance = start_x - target_x
  786. if distance <= 0:
  787. result = [(int(target_x), int(start_y))]
  788. return (result, [0.0]) if return_schedule else result
  789. scale = W / 1220.0
  790. if distance > 600 * scale:
  791. cat = "long"
  792. elif distance > 450 * scale:
  793. cat = "medium"
  794. else:
  795. cat = "short"
  796. # 2026-09-22 19条实测: 折回Y漂移全部向下(比值0.19~0.39, 90~240px);
  797. # 旧三档±小漂移与真人方向相反, 改为单一向下分布+硬上限。
  798. end_ratio = random.triangular(0.20, 0.38, 0.30)
  799. if duration_s is None:
  800. duration_s = _return_duration(distance, scale)
  801. templates = _load_guiji_templates()
  802. geometry_templates = templates.get(cat, [])
  803. geometry = random.choice(geometry_templates) if geometry_templates else None
  804. end_dy_limit = int(round(RETURN_END_DRIFT_LIMIT_1220 * scale))
  805. end_dy = int(_clamp(round(end_ratio * distance), -end_dy_limit, end_dy_limit))
  806. target_y = start_y + end_dy
  807. shape_gain = random.uniform(0.82, 1.08)
  808. shape_sign = random.choice((-1.0, 1.0))
  809. curve_limit_px = {
  810. "short": 30.0,
  811. "medium": 36.0,
  812. "long": float(RETURN_CURVE_LIMIT_1220),
  813. }[cat] * scale
  814. residual_limit = min(
  815. {"short": 0.06, "medium": 0.075, "long": 0.115}[cat],
  816. curve_limit_px / max(distance, 1.0),
  817. )
  818. fallback_lo = min({"short": 0.020, "medium": 0.035, "long": 0.050}[cat],
  819. residual_limit)
  820. fallback_hi = min({"short": 0.055, "medium": 0.075, "long": 0.115}[cat],
  821. residual_limit)
  822. fallback_amp = random.uniform(fallback_lo, max(fallback_lo, fallback_hi)) \
  823. * random.choice((-1.0, 1.0))
  824. # ── 折回速度剖面(2026-09-23 真人19条按距离分桶实测) ──
  825. # 距离越长巡航越快(短0.75/中1.07/长1.95 px/ms), 减速越早越狠,
  826. # 总时长稳定在2.3~2.8s。锚点链(距缺口px, 点速度/巡航)对数线性
  827. # 插值; 点速度由实测的"0~N px区间均值"反推(区间均值≠边界点速度)。
  828. # 长折按真人逐点曲线校准(150px:0.81 → 100px:0.46 → 70px:0.25 →
  829. # 50px:0.18 → 30px:0.09 → 20px:0.03): 减速段占总时长47~69%;
  830. # 旧链(100px就掉到0.08)减速段膨胀到81%, 太慢。
  831. # 减速起点与锚点距离按屏幕宽度缩放: 真人样本来自1220宽屏, 小屏上
  832. # 同样的绝对px会让减速区占比过大(720屏上256px折回65%在减速)。
  833. if cat == "long":
  834. v_peak = random.uniform(1.6, 2.4) * max(scale, 0.8)
  835. chain = ((100, 0.40), (70, 0.25), (50, 0.16), (30, 0.08), (20, 0.04), (10, 0.02), (5, 0.01))
  836. elif cat == "medium":
  837. v_peak = random.uniform(0.7, 1.3) * max(scale, 0.8)
  838. chain = ((100, 0.26), (50, 0.15), (20, 0.10), (10, 0.07), (5, 0.01))
  839. else:
  840. v_peak = random.uniform(0.45, 0.9) * max(scale, 0.8)
  841. # 短折按200~300px样本(100px:0.48, 50px:0.31, 20px:0.09, 10px:0.04)校准
  842. chain = ((100, 0.45), (50, 0.28), (20, 0.12), (10, 0.04), (5, 0.02))
  843. chain = tuple((max(2.0, round(d * scale)), r) for d, r in chain)
  844. if distance >= 250:
  845. dec_start = 150.0 * scale
  846. else:
  847. dec_start = max(80.0 * scale, distance * 0.55)
  848. anchors = [(dec_start, 1.0)] + [(d, r) for d, r in chain if d < dec_start]
  849. ramp_end = distance * random.uniform(0.20, 0.30) # 起步加速完成点(已走px, 采样一次)
  850. def _smoothstep(f):
  851. f = min(1.0, max(0.0, f))
  852. return f * f * (3 - 2 * f) # C1连续, 无折角
  853. def _v_of_u(u):
  854. d = (1.0 - u) * distance # 距缺口 px
  855. if d >= dec_start:
  856. # 巡航段: 前25~38%路程内加速到峰值(真人加速快), 之后巡航保持
  857. progressed = distance - d
  858. f = progressed / max(ramp_end, 1e-6)
  859. return v_peak * (0.25 + 0.75 * _smoothstep(f))
  860. if distance < 150:
  861. # 超短折回: 全程巡航, 最后8px收一下
  862. return v_peak * 0.06 if d <= 8 else v_peak
  863. for (d1, r1), (d2, r2) in zip(anchors, anchors[1:]):
  864. if d <= d1 and d > d2:
  865. f = min(1.0, (d1 - d) / max(d1 - d2, 1e-6))
  866. return v_peak * math.exp(math.log(r1) + math.log(r2 / r1) * f)
  867. return v_peak * anchors[-1][1] # d<=5px: 爬行
  868. prof_u, prof_t = [], []
  869. t_cur = 0.0
  870. u_cur = 0.0
  871. # 按真人事件率时间步进采样: 实测折回段dt中位4.4ms(重尾3.8~7.5ms),
  872. # 全程连续出事件, 尾段一像素一像素磨进缺口。均匀路程采样(旧100点)
  873. # 会把尾段变成4~5px一跳/90ms一顿, 与真人相反。
  874. while u_cur < 1.0 - 1e-9:
  875. v = _v_of_u(u_cur) * random.uniform(0.97, 1.03) # ±3%微扰
  876. # 巡航段偶发30~80ms微犹豫(约1.5%事件, 实测偶见); 最后15%路程
  877. # 贴入缺口时保持连续, 不插任何停顿。
  878. if random.random() < 0.015 and u_cur < 0.85:
  879. dt_ms = random.uniform(30.0, 80.0)
  880. else:
  881. # 真人事件间隔中位4.4ms, 但主机注入层稳定维持的下限实测约10ms
  882. # (Windows偶发10~360ms抢占; test6用15~30ms间隔从不卡顿)。
  883. # 降到10~14ms: 保留真人速度剖面与尾段1px连续贴入, 给调度留余量。
  884. dt_ms = random.uniform(10.0, 14.0)
  885. # 刹车锚点链首段只有50px宽: 接近时限制单步距离, 避免一步跳过
  886. # (长折巡航步长可达25~30px)。dt收缩但不低于3.5ms。
  887. d_cur = (1.0 - u_cur) * distance
  888. if d_cur < dec_start + 20.0:
  889. step_px = v * dt_ms
  890. if step_px > 3.0:
  891. dt_ms = max(dt_ms * 3.0 / step_px, 3.5)
  892. u_cur = min(1.0, u_cur + v * dt_ms / distance)
  893. t_cur += dt_ms
  894. prof_u.append(u_cur)
  895. prof_t.append(t_cur)
  896. event_u = np.asarray(prof_u, dtype=np.float64)
  897. event_t = np.asarray(prof_t, dtype=np.float64) / max(1.0, prof_t[-1]) # 归一化0~1
  898. duration_s = prof_t[-1] / 1000.0 # 剖面自然时长(s)
  899. track = []
  900. max_residual = 0.0
  901. max_y_deviation = 0.0
  902. y_corridor = RETURN_Y_CORRIDOR_1220 * scale
  903. for t, u in zip(event_t, event_u):
  904. residual = (_template_residual(geometry, u) * shape_sign * shape_gain
  905. if geometry else _fallback_bow(float(u), cat, fallback_amp))
  906. residual = _clamp(residual, -residual_limit, residual_limit)
  907. max_residual = max(max_residual, abs(residual))
  908. x = start_x - distance * float(u)
  909. y = start_y + end_dy * float(u) + residual * distance
  910. y = _clamp(y, start_y - y_corridor, start_y + y_corridor)
  911. max_y_deviation = max(max_y_deviation, abs(y - start_y))
  912. track.append((int(round(x)), int(round(y))))
  913. if track:
  914. track[0] = (int(start_x), int(start_y))
  915. track[-1] = (int(target_x), int(target_y))
  916. # 取整后相同的坐标不再重复发送;下一次不同坐标的时间仍保留,
  917. # 这样末端是短暂停顿而不是高频轰炸同一个像素。
  918. compact_track = []
  919. compact_t = []
  920. for point, t in zip(track, event_t):
  921. if not compact_track or point != compact_track[-1]:
  922. compact_track.append(point)
  923. compact_t.append(float(t))
  924. track = compact_track
  925. event_t = np.asarray(compact_t, dtype=np.float64)
  926. if len(event_t) > 1:
  927. event_t[0] = 0.0
  928. event_t[-1] = 1.0
  929. schedule = (event_t * float(duration_s)).tolist()
  930. print(f" 折回: {cat} distance={distance:.0f}px {len(track)}点 "
  931. f"Y漂={end_dy:+d} 弯曲={max_residual * distance:.0f}px "
  932. f"比例={max_residual * 100:.1f}% Y范围={max_y_deviation:.0f}px "
  933. f"速度=人速剖面 时长={duration_s * 1000:.0f}ms")
  934. return (track, schedule) if return_schedule else track
  935. # Legacy procedural branch kept below for rollback during validation.
  936. if distance > 600 * scale:
  937. cat = "long"
  938. steps = int(_clamp(distance / random.uniform(2.8, 4.1), 180, 380))
  939. end_ratio = random.triangular(-0.045, 0.12, 0.035)
  940. elif distance > 450 * scale:
  941. cat = "medium"
  942. steps = int(_clamp(distance / random.uniform(2.7, 4.2), 130, 250))
  943. end_ratio = random.triangular(-0.035, 0.10, 0.025)
  944. else:
  945. cat = "short"
  946. steps = int(_clamp(distance / random.uniform(2.0, 3.8), 75, 210))
  947. end_ratio = random.triangular(-0.025, 0.08, 0.018)
  948. end_dy = int(round(end_ratio * distance))
  949. templates = _load_guiji_templates().get(cat, [])
  950. template = random.choice(templates) if templates else None
  951. shape_gain = random.uniform(0.80, 1.12)
  952. shape_sign = random.choice((-1.0, 1.0))
  953. fallback_amp = random.uniform(
  954. {"short": 0.020, "medium": 0.035, "long": 0.050}[cat],
  955. {"short": 0.055, "medium": 0.075, "long": 0.115}[cat],
  956. ) * random.choice((-1.0, 1.0))
  957. x_exp = random.uniform(1.55, 2.05)
  958. linear_tail = random.uniform(0.16, 0.24)
  959. target_y = start_y + end_dy
  960. track = []
  961. max_residual = 0.0
  962. for i in range(steps):
  963. t = i / max(1, steps - 1)
  964. # Horizontal movement eases into the target. The residual is a
  965. # single broad bow learned from guiji, rather than random wobble.
  966. ease = 1.0 - (1.0 - t) ** x_exp
  967. u = ease * (1.0 - linear_tail) + t * linear_tail
  968. x = start_x - distance * u
  969. residual = (_template_residual(template, u) * shape_sign * shape_gain
  970. if template else _fallback_bow(u, cat, fallback_amp))
  971. residual_limit = {"short": 0.06, "medium": 0.075, "long": 0.115}[cat]
  972. residual = _clamp(residual, -residual_limit, residual_limit)
  973. max_residual = max(max_residual, abs(residual))
  974. chord_y = start_y + end_dy * u
  975. y = chord_y + residual * distance
  976. track.append((int(round(x)), int(round(y))))
  977. if track:
  978. track[-1] = (int(target_x), int(target_y))
  979. print(f" 折回: {cat} distance={distance:.0f}px {len(track)}点 "
  980. f"Y漂={end_dy:+d} 弯曲={max_residual * distance:.0f}px "
  981. f"比例={max_residual * 100:.1f}%")
  982. return _dedupe_track(track)
  983. # Legacy endpoint-easing branch kept below for rollback during validation.
  984. scale = W / 1220.0
  985. if distance > 600 * scale:
  986. # Keep the long return close to the slider rail. A 300+ px Y drift
  987. # puts the final events outside the control and the device stops
  988. # applying the horizontal motion reliably.
  989. steps = int(_clamp(distance / random.uniform(4.0, 6.0), 150, 210))
  990. end_dy = int(round(random.triangular(60, 180, 110) * scale))
  991. cat = '长折'
  992. elif distance > 450 * scale:
  993. steps = int(_clamp(distance / random.uniform(3.0, 5.0), 110, 180))
  994. end_dy = int(round(random.triangular(-25, 45, 10) * scale))
  995. cat = '中折'
  996. else:
  997. steps = int(_clamp(distance / random.uniform(1.2, 2.2), 80, 240))
  998. end_dy = int(round(random.triangular(0, 135, 40) * scale))
  999. cat = '短折'
  1000. x_exp = random.uniform(1.55, 2.10)
  1001. y_exp = random.uniform(1.15, 1.85)
  1002. print(f' 折回: {cat} distance={distance:.0f}px {steps}点 Y漂=+{end_dy} '
  1003. f'X弧度={x_exp:.2f} Y弧度={y_exp:.2f}')
  1004. target_y = start_y + end_dy
  1005. track = []
  1006. for i in range(1, steps + 1):
  1007. t = i / steps
  1008. # 末端保留 12% 线性分量,避免取整后长时间停在同一坐标。
  1009. ease = 1 - (1 - t) ** x_exp
  1010. x = start_x - distance * (ease * 0.88 + t * 0.12)
  1011. y = start_y + (target_y - start_y) * (1 - (1 - t) ** y_exp)
  1012. track.append((int(round(x)), int(round(y))))
  1013. if track:
  1014. track[-1] = (int(target_x), int(target_y))
  1015. track = _dedupe_track(track)
  1016. return _dedupe_track(track)
  1017. def save_track_image(pts, filepath):
  1018. if len(pts) < 2: return
  1019. xs=[p[0] for p in pts]; ys=[p[1] for p in pts]; m=50
  1020. w=max(xs)-min(xs)+m*2; h=max(ys)-min(ys)+m*2
  1021. w,h=max(w,200),max(h,100)
  1022. c=np.ones((h,w,3),dtype=np.uint8)*255
  1023. for i in range(1,len(pts)):
  1024. 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
  1025. cv2.line(c,(pts[i-1][0]-min(xs)+m,pts[i-1][1]-min(ys)+m),
  1026. (pts[i][0]-min(xs)+m,pts[i][1]-min(ys)+m),(0,g,b),1)
  1027. cv2.circle(c,(pts[0][0]-min(xs)+m,pts[0][1]-min(ys)+m),4,(0,200,0),-1)
  1028. cv2.circle(c,(pts[-1][0]-min(xs)+m,pts[-1][1]-min(ys)+m),4,(0,0,200),-1)
  1029. cv2.imwrite(filepath,c)
  1030. def _timing_selfcheck():
  1031. """开跑前自检主机计时稳定性: 100次12ms sleep, 统计超睡>8ms的比例。
  1032. 机器负载窗口(杀毒/磁盘/USB抢占)时 sleep 超睡可达30~70ms, 该窗口
  1033. 内发送必然卡顿, 提前告警比跑完看轨迹划算。"""
  1034. overs = 0
  1035. for _ in range(100):
  1036. s = time.perf_counter()
  1037. time.sleep(0.012)
  1038. if (time.perf_counter() - s) - 0.012 > 0.008:
  1039. overs += 1
  1040. if overs <= 2:
  1041. print(f" 计时自检: OK ({overs}/100 次超睡)")
  1042. return True
  1043. print(f" ⚠ 计时自检: 不稳 ({overs}/100 次超睡>8ms), 主机处于负载窗口, "
  1044. f"轨迹会卡顿, 建议稍后再跑")
  1045. return False
  1046. def solve_slider(driver, sx=None):
  1047. global d, W, H
  1048. d = driver; W, H = d.window_size(); d.screen_on()
  1049. if sx is None:
  1050. sx = 163 if W > 1000 else 87
  1051. print(f" 屏幕: {W}x{H} -> sx={sx}")
  1052. _timing_selfcheck()
  1053. screen = d.screenshot(format="opencv")
  1054. slider_y = y_top = slider_bottom = None
  1055. try:
  1056. cv2.imwrite(os.path.join(OUT, "_tmp_slider.png"), screen)
  1057. info = detect_slider_button(os.path.join(OUT, "_tmp_slider.png"))
  1058. coords = info[0] if isinstance(info, tuple) else info
  1059. if isinstance(coords, dict):
  1060. sx = int(round((coords["top_left"][0] + coords["bottom_right"][0]) / 2))
  1061. except Exception as exc:
  1062. print(f" detect_slider_button 失败: {exc}")
  1063. initial_button_center = _detect_slider_center(screen)
  1064. handle_center_offset = ((initial_button_center - sx)
  1065. if initial_button_center is not None else 0.0)
  1066. ocr_r = ocr_eng(screen)
  1067. if ocr_r and ocr_r[0]:
  1068. for item in ocr_r[0]:
  1069. t = item[1]; cy = int((item[0][0][1]+item[0][2][1])/2)
  1070. # 淘宝滑块两种常见提示文字都认
  1071. if "请按照说明拖动滑块" in t or "请按住滑块" in t:
  1072. slider_y = slider_y or cy; slider_bottom = int(item[0][2][1])
  1073. if "松开" in t: y_top = int(item[0][0][1])
  1074. if slider_y is None:
  1075. texts = [it[1] for it in (ocr_r[0] if ocr_r and ocr_r[0] else [])]
  1076. print(f" 未检测到滑块提示文字, 本次不执行")
  1077. print(f" 当前屏幕OCR文本: {texts[:15]}")
  1078. cv2.imwrite(os.path.join(OUT, "_no_captcha.png"), screen)
  1079. print(f" 已保存截图: {os.path.join(OUT, '_no_captcha.png')} (确认验证码是否已弹出)")
  1080. return False
  1081. sy = slider_y
  1082. # 右滑 — TouchPipe;injectInputEvent 第四参数固定为 0
  1083. right_end_x = _choose_right_end_x(sx, W)
  1084. print(f" 右滑: 按住({sx},{sy}) -> 折返点({right_end_x})")
  1085. touch_started = time.perf_counter()
  1086. d.touch.down(sx, sy)
  1087. down_hold_s = random.uniform(0.06, 0.12) # 实测初始按压停顿中位75ms(62~122ms)
  1088. time.sleep(down_hold_s)
  1089. pipe = TouchPipe(d).open()
  1090. t0 = time.perf_counter(); track_pts = []
  1091. # 优先使用真人右段速度模板(爆发起步→长弧刹车, 含原始微犹豫);
  1092. # 模板不可用时退回匀速+抖动方案。
  1093. rvel_list = _load_guiji_templates().get("velocity_right") or []
  1094. right_velocity = random.choice(rvel_list) if rvel_list else None
  1095. if right_velocity is not None:
  1096. right_target_s = _clamp(
  1097. right_velocity["duration_ms"] / 1000.0
  1098. * (right_end_x - sx) / max(right_velocity["span"], 1.0),
  1099. *RIGHT_TEMPLATE_DURATION_RANGE)
  1100. # 模板曲线按7~9ms均匀时间网格重采样: 保留"爆发→刹车"的速度
  1101. # 剖面, 密度与主机注入能力匹配(直接过滤原始事件会因模板而异,
  1102. # 曾出现整段只剩32点的情况; 4~5ms间隔在负载窗口下不稳)
  1103. right_point_count = max(12, int(right_target_s / random.uniform(0.007, 0.009)) + 1)
  1104. t_grid = [i / (right_point_count - 1) for i in range(right_point_count)]
  1105. u_grid = [float(np.interp(ti, right_velocity["t"], right_velocity["progress"]))
  1106. for ti in t_grid]
  1107. right_velocity = {"event_t": t_grid, "event_progress": u_grid,
  1108. "duration_ms": right_velocity["duration_ms"],
  1109. "span": right_velocity["span"]}
  1110. right_sample_interval_s = 0.0
  1111. else:
  1112. right_target_s = _clamp((right_end_x - sx) / random.uniform(*RIGHT_SPEED_RANGE) / 1000.0, 0.18, 1.3)
  1113. right_sample_interval_s = random.uniform(*RIGHT_SAMPLE_INTERVAL_RANGE)
  1114. right_point_count = int(round(_clamp(
  1115. right_target_s / right_sample_interval_s + 1,
  1116. RIGHT_POINT_LIMITS[0], RIGHT_POINT_LIMITS[1],
  1117. )))
  1118. right_track, right_schedule = _build_right_track(
  1119. sx, sy, right_end_x, right_point_count,
  1120. velocity_template=right_velocity, duration_s=right_target_s,
  1121. )
  1122. # 峰值段单步40px+太猛, 拆成<=16px的小步(时间表同步拆分)
  1123. right_track, right_schedule = _subdivide_track(right_track, right_schedule)
  1124. right_elapsed_s, right_emit = _emit_track(
  1125. pipe, right_track, 'right', track_pts, t0, right_target_s,
  1126. schedule_override=right_schedule
  1127. )
  1128. right_emit["requested_points"] = right_point_count
  1129. right_emit["sample_interval_ms"] = right_sample_interval_s * 1000
  1130. right_emit["schedule_mode"] = "real_template" if right_velocity is not None else "uniform"
  1131. right_finished = time.perf_counter()
  1132. print(f" 右滑[{right_emit['schedule_mode']}]: 计划{len(right_track)}点/实发{right_emit['sent_points']}点 "
  1133. f"重同步{right_emit['resyncs']}次(+{right_emit['shifted_ms']:.0f}ms) "
  1134. f"{right_elapsed_s*1000:.0f}ms lag95={right_emit['lag_p95_ms']:.1f}ms")
  1135. settle_s = random.uniform(*RIGHT_SETTLE_RANGE)
  1136. time.sleep(settle_s)
  1137. # TouchPipe 的请求可能仍在设备端排队。重复发送右端点并以真实滑块
  1138. # 中心确认到右侧后,才截取给接口的图片,避免幕布尚未展开。
  1139. # 手指可以发送到 1190,但滑块按钮受滑轨右边界限制:1220 宽屏上
  1140. # 滑轨约止于 1130,154px 宽的按钮中心最大约为 1053。
  1141. # 因此右端确认必须按 UI 的物理极限判断,不能按手指折返点判断。
  1142. scale = W / 1220.0
  1143. right_min_center = max(0, int(round(W - 180 * scale)))
  1144. unfolded, right_center, right_ready_ms, right_ready = _wait_for_slider_position(
  1145. pipe, d, right_end_x, right_track[-1][1],
  1146. track_pts=track_pts, t0=t0, phase="right_confirm",
  1147. min_center=right_min_center, timeout_s=RIGHT_READY_TIMEOUT_S,
  1148. label="右端到位"
  1149. )
  1150. print(f" 右端确认: center="
  1151. f"{right_center if right_center is not None else '未检测到'} "
  1152. f"目标>={right_min_center} wait={right_ready_ms:.0f}ms "
  1153. f"{'OK' if right_ready else 'TIMEOUT'}")
  1154. if not right_ready:
  1155. print(f" 右端到位未确认,仍使用最后截图 center="
  1156. f"{right_center if right_center is not None else '未检测到'}")
  1157. if unfolded is None:
  1158. unfolded = d.screenshot(format="opencv")
  1159. if not right_ready:
  1160. cv2.imwrite(os.path.join(OUT, "_unfold_not_ready.png"), unfolded)
  1161. print(" 幕布未确认完全展开,本次不调用接口,避免使用错误坐标")
  1162. _print_touchpipe_diagnostics(pipe)
  1163. pipe.close()
  1164. d.touch.up(int(right_end_x), int(right_track[-1][1]))
  1165. return False
  1166. # JFBYM
  1167. # 先做一次渲染/事件队列冲刷,再截取真正提交给识别接口的画面。
  1168. time.sleep(random.uniform(0.06, 0.12))
  1169. crop = d.screenshot(format="opencv")
  1170. if y_top and slider_bottom: crop = crop[y_top:slider_bottom, :]
  1171. _, buf = cv2.imencode(".png", crop)
  1172. gap = None
  1173. api_started = time.perf_counter()
  1174. for a in range(3):
  1175. try:
  1176. r = requests.post(JFBYM_URL, json={"token":TOKEN,"type":"20226","image":base64.b64encode(buf).decode()}, timeout=35).json()
  1177. if r.get("data") and r["data"].get("data"): gap = int(r["data"]["data"])
  1178. elif r.get("data") and isinstance(r["data"],(int,float)): gap = int(r["data"])
  1179. if gap is not None: break
  1180. time.sleep(2)
  1181. except: time.sleep(2)
  1182. api_elapsed_s = time.perf_counter() - api_started
  1183. if gap is None:
  1184. _print_touchpipe_diagnostics(pipe)
  1185. pipe.close()
  1186. d.touch.up(*right_track[-1])
  1187. return False
  1188. print(f" gap={gap}")
  1189. # 折返点停顿: 真人中位0.2~0.6s(37~1014ms), 旧值2.5~3.2s比真人长
  1190. # 5~10倍(旧注释"该档失败率仅12%", 但现在轨迹已全面真人化, 重新A/B)。
  1191. # 打码API必须在停顿期间完成(实测0.6~2.0s), 因此改为API返回后再补
  1192. # 0.15~0.35s余量, 总停顿≈API耗时+小余量(约1.0~2.4s)。
  1193. time.sleep(random.uniform(0.15, 0.35))
  1194. # 折回 — 按距离确定点数和阶段时长
  1195. turn_x, turn_y = right_track[-1]
  1196. raw_target_x = gap + OFFSET_COMPENSATE
  1197. target_x = int(_clamp(raw_target_x, 10, turn_x - 1))
  1198. if target_x != raw_target_x:
  1199. print(f" 目标X超出折返范围: {raw_target_x} -> {target_x}")
  1200. return_target_s = _return_duration(turn_x - target_x, W / 1220.0)
  1201. return_track, return_schedule = _build_human_return_track(
  1202. turn_x, turn_y, target_x, return_target_s, return_schedule=True
  1203. )
  1204. # 巡航段单步可达20px+, 拆成<=16px的小步让加速段更平滑
  1205. return_track, return_schedule = _subdivide_track(return_track, return_schedule)
  1206. return_target_s = float(return_schedule[-1]) if return_schedule else return_target_s
  1207. hold_ms = (time.perf_counter() - right_finished) * 1000
  1208. return_elapsed_s, return_emit = _emit_track(
  1209. pipe, return_track, 'return', track_pts, t0, return_target_s,
  1210. schedule_override=return_schedule
  1211. )
  1212. return_emit["schedule_mode"] = "fast_then_brake"
  1213. print(f" 折回: 计划{len(return_track)}点/实发{return_emit['sent_points']}点 "
  1214. f"重同步{return_emit['resyncs']}次(+{return_emit['shifted_ms']:.0f}ms) "
  1215. f"{return_elapsed_s*1000:.0f}ms "
  1216. f"lag95={return_emit['lag_p95_ms']:.1f}ms 远端停顿={hold_ms:.0f}ms")
  1217. # 回滑同样不能把“最后一个已发送点”当成“设备已经到位”。
  1218. # 轮询真实滑块中心,并重复发送目标点,直到旧事件队列被消化。
  1219. target_center = target_x + handle_center_offset
  1220. return_ready_frame, return_center, return_ready_ms, return_ready = (
  1221. _wait_for_slider_position(
  1222. pipe, d, target_x, return_track[-1][1],
  1223. track_pts=track_pts, t0=t0, phase="return_confirm",
  1224. expected_center=target_center, timeout_s=RETURN_READY_TIMEOUT_S,
  1225. stable_required=1,
  1226. label="回滑到位"
  1227. )
  1228. )
  1229. if not return_ready:
  1230. print(f" 回滑到位未确认,释放前实际 center="
  1231. f"{return_center if return_center is not None else '未检测到'}")
  1232. if return_center is not None:
  1233. _err = return_center - (target_x + handle_center_offset)
  1234. if abs(_err) > 20:
  1235. print(f" ⚠ 落点离群 {_err:+.0f}px (目标{target_x + handle_center_offset:.0f}, 实际{return_center:.0f}) — 打码gap可能错误")
  1236. phase_timing = {
  1237. "down_hold_ms": down_hold_s * 1000,
  1238. "right_ms": right_elapsed_s * 1000,
  1239. "right_emit": right_emit,
  1240. "settle_ms": settle_s * 1000,
  1241. "right_ready_ms": right_ready_ms,
  1242. "right_ready": right_ready,
  1243. "right_center": right_center,
  1244. "api_ms": api_elapsed_s * 1000,
  1245. "hold_ms": hold_ms,
  1246. "return_ms": return_elapsed_s * 1000,
  1247. "return_emit": return_emit,
  1248. "return_ready_ms": return_ready_ms,
  1249. "return_ready": return_ready,
  1250. "return_center": return_center,
  1251. }
  1252. # 到位处理:以滑块按钮的真实中心确认,不再把幕布橙色左边缘
  1253. # 直接和 API gap 相减。两者不是同一个物理坐标点。
  1254. alignment_started = time.perf_counter()
  1255. aligned = return_ready
  1256. last_diff = None
  1257. correction_count = 0
  1258. cur_x, cur_y = return_track[-1]
  1259. # ── 释放前慢速微调: API gap 与真实缺口(幕布左边缘)常有几px差距 ──
  1260. # 用幕布左边缘量出剩余距离, 像真人一样1px/30~90ms慢慢挪过去。
  1261. micro_px = 0
  1262. micro_ms = 0.0
  1263. micro_edge = None
  1264. try:
  1265. check = d.screenshot(format="opencv")
  1266. micro_edge, _ = detect_captcha_left_edge(check)
  1267. micro_px = int(round(micro_edge - cur_x))
  1268. except Exception as exc:
  1269. print(f" 幕布边缘检测失败({exc}), 跳过微调")
  1270. if 2 < abs(micro_px) <= 12:
  1271. direction = 1 if micro_px > 0 else -1
  1272. start_x = cur_x
  1273. micro_started = time.perf_counter()
  1274. for _ in range(abs(micro_px)):
  1275. cur_x += direction
  1276. send_ms = 0.0
  1277. try:
  1278. send_ms = pipe.move(int(cur_x), int(cur_y), 0)
  1279. except Exception:
  1280. pass
  1281. track_pts.append({"x": int(cur_x), "y": int(cur_y), "pressure": 0,
  1282. "phase": "micro", "confirm": False,
  1283. "scheduled_ms": None, "lag_ms": None,
  1284. "send_ms": send_ms,
  1285. "rel_ms": (time.perf_counter()-t0)*1000})
  1286. time.sleep(random.uniform(0.03, 0.09))
  1287. micro_ms = (time.perf_counter() - micro_started) * 1000
  1288. print(f" 微调: 幕布边缘{micro_edge:.0f} vs 当前位置{start_x} "
  1289. f"差{micro_px:+d}px, {abs(micro_px)}步慢挪 {micro_ms:.0f}ms")
  1290. elif abs(micro_px) > 12:
  1291. print(f" 幕布边缘偏差{micro_px:+d}px过大(可能检测错误), 不做微调")
  1292. phase_timing["micro_adjust_px"] = micro_px
  1293. phase_timing["micro_adjust_ms"] = micro_ms
  1294. print(f" 回滑确认: 滑块中心="
  1295. f"{return_center if return_center is not None else '未检测到'} "
  1296. f"目标中心={target_center:.0f} wait={return_ready_ms:.0f}ms "
  1297. f"{'OK' if return_ready else 'TIMEOUT'}")
  1298. aligned_img = return_ready_frame
  1299. # 微调(默认关闭;如果启用 fixed,仍以 API 目标点为释放点)
  1300. if MICRO_MODE == "fixed":
  1301. target_cur_x = int(_clamp(cur_x + OFFSET_COMPENSATE, 10, W-10))
  1302. steps = abs(target_cur_x - cur_x)
  1303. sign = 1 if target_cur_x > cur_x else -1
  1304. for s in range(steps):
  1305. cur_x += sign
  1306. pipe.move(cur_x, cur_y, 0)
  1307. time.sleep(random.uniform(0.005, 0.010))
  1308. print(f" 固定偏移: {OFFSET_COMPENSATE:+.0f}px 分{steps}步 -> x={cur_x}")
  1309. elif MICRO_MODE == "visual":
  1310. fallback_x = cur_x; prev_pl = None
  1311. for attempt in range(3):
  1312. time.sleep(0.3)
  1313. check = d.screenshot(format="opencv")
  1314. cv2.imwrite(os.path.join(ALIGN_DIR, f"align_{attempt}.png"), check)
  1315. try: pl, _ = detect_captcha_left_edge(check)
  1316. except Exception: break
  1317. diff = pl - gap
  1318. marked = check.copy()
  1319. cv2.line(marked, (pl, 0), (pl, marked.shape[0]), (0, 255, 0), 3)
  1320. cv2.line(marked, (gap, 0), (gap, marked.shape[0]), (0, 0, 255), 3)
  1321. cv2.imwrite(os.path.join(ALIGN_DIR, f"align_{attempt}_marked.png"), marked)
  1322. if abs(diff) <= 5: break
  1323. if prev_pl is not None and pl == prev_pl:
  1324. cur_x = int(_clamp(fallback_x,10,W-10)); pipe.move(cur_x,cur_y,0); break
  1325. prev_pl = pl
  1326. cur_x = int(_clamp(cur_x-diff,10,W-10)); pipe.move(cur_x,cur_y,0); time.sleep(0.05)
  1327. # MICRO_MODE=none has already obtained a fresh confirmation frame above;
  1328. # avoid another synchronous screenshot before releasing the finger.
  1329. if MICRO_MODE != "none" or aligned_img is None:
  1330. aligned_img = d.screenshot(format="opencv")
  1331. phase_timing["alignment_ms"] = (time.perf_counter() - alignment_started) * 1000
  1332. phase_timing["alignment_corrections"] = correction_count
  1333. pipe_diag = _print_touchpipe_diagnostics(pipe)
  1334. phase_timing["touchpipe"] = pipe_diag
  1335. # 真人释放前静默停顿40~120ms(实测中位43ms), 期间不发送任何触摸事件
  1336. release_pause_s = random.uniform(0.04, 0.12)
  1337. time.sleep(release_pause_s)
  1338. phase_timing["release_pause_ms"] = release_pause_s * 1000
  1339. pipe.close(); d.touch.up(int(cur_x), int(cur_y))
  1340. phase_timing["touch_total_ms"] = (time.perf_counter() - touch_started) * 1000
  1341. time.sleep(2)
  1342. # 验证
  1343. final = d.screenshot(format="opencv"); check_r = ocr_eng(final)
  1344. passed = True
  1345. if check_r and check_r[0]:
  1346. if any("拖动滑块" in it[1] or "请按住滑块" in it[1] or "安全验证" in it[1] for it in check_r[0]): passed = False
  1347. if not passed:
  1348. time.sleep(2); final = d.screenshot(format="opencv"); check_r = ocr_eng(final)
  1349. passed = True
  1350. if check_r and check_r[0]:
  1351. if any("拖动滑块" in it[1] or "请按住滑块" in it[1] or "安全验证" in it[1] for it in check_r[0]): passed = False
  1352. print(f" 结果: {'OK' if passed else 'FAIL'}")
  1353. # 图片和 JSON 使用实际发送点;被调度器跳过的计划点不再画进轨迹。
  1354. all_track = [(item["x"], item["y"]) for item in track_pts]
  1355. rd = SUCCESS_DIR if passed else FAILURE_DIR
  1356. now = time.localtime()
  1357. dev_id = getattr(d, 'serial', getattr(d, '_serial', 'unknown'))
  1358. 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}"
  1359. save_track_image(all_track, os.path.join(rd, f"{prefix}_track.png"))
  1360. if aligned_img is not None:
  1361. b_img = aligned_img.copy()
  1362. for i in range(1, len(all_track)):
  1363. cv2.line(b_img, all_track[i-1], all_track[i], (0, 200, 200), 2)
  1364. cv2.circle(b_img, all_track[0], 6, (0, 255, 0), -1)
  1365. cv2.circle(b_img, all_track[-1], 6, (0, 0, 255), -1)
  1366. cv2.imwrite(os.path.join(rd, f"{prefix}_b.png"), b_img)
  1367. tj = {"device": dev_id, "screen": {"w": W, "h": H},
  1368. "time": time.strftime("%Y-%m-%d %H:%M:%S", now),
  1369. "total_points": len(track_pts),
  1370. "duration_ms": track_pts[-1]["rel_ms"] if track_pts else 0,
  1371. "phase_timing": phase_timing,
  1372. "start": {"x": track_pts[0]["x"], "y": track_pts[0]["y"]},
  1373. "turn": {"x": turn_x, "y": turn_y},
  1374. "end": {"x": track_pts[-1]["x"], "y": track_pts[-1]["y"]},
  1375. "dx": track_pts[-1]["x"]-track_pts[0]["x"],
  1376. "dy": track_pts[-1]["y"]-track_pts[0]["y"],
  1377. "passed": passed, "gap": gap,
  1378. "points": track_pts}
  1379. with open(os.path.join(rd, f"{prefix}_track.json"), 'w', encoding='utf-8') as fp:
  1380. json.dump(tj, fp, ensure_ascii=False)
  1381. return passed
  1382. if __name__ == "__main__":
  1383. DEVICE = "8XHEJBHMZHTKYTHM"
  1384. d = u2.connect(DEVICE)
  1385. print(f"设备: {DEVICE}")
  1386. ok = solve_slider(d)
  1387. print(f"退出: {'通过' if ok else '未通过/未执行'}")
  1388. sys.exit(0 if ok else 1)