22 KiB
实验演进记录(A → L)
最终方案与使用说明见 solution.md。 本文记录每个方法的思路、失败原因与沉淀的经验教训, 原始实验脚本在
try/,输出在try/out/<方法字母>/。
方法总览
| 方法 | 脚本 | 思路 | 结论 |
|---|---|---|---|
| A | method_a_template.py | 蒙版 CCORR 多尺度模板匹配 | 废弃 |
| A2 | method_a2_ccoeff.py | 蒙版 CCOEFF + 峰显著度 + NMS | 基线可用,但锁纹理 |
| B | method_b_sift.py | SIFT + RANSAC 迭代聚类 | 仅 2 样本可用,留作真值锚点 |
| C | method_c_rotation.py | NCC 粗扫 + 旋转响应二次评分 | 第一阶段候选即错 |
| D | method_d_shape.py | Chamfer 轮廓距离 | 小尺度纹理饱和 |
| E | method_e_shape_coverage.py | 轮廓边缘覆盖率 | 同上 |
| F | method_f_contour.py | Canny 轮廓 + matchShapes | 背景轮廓合并成超大块 |
| G | method_g_component.py | 固定暗度阈值连通域 | 阈值敏感 |
| H | method_h_silhouette.py | alpha 轮廓距离 + 局部对比度 | 分数归一化饱和 |
| I | method_i_adaptive_components.py | 自适应暗目标连通域 | 误检照片内容 |
| J | method_j_objects.py | 低饱和度暗色物体分割 | 同上 |
| K | method_k_objectness.py | 暗色覆盖物概率图 + 掩码 CCORR | 掩码 CCORR 边界产生 inf |
| L | src/method_l_shape.py |
方向感知倒角 + 洞内部特征 + 旋转扫描 | 最终方案,10/10 |
逐方法教训
A / A2:RGB 模板匹配(不减小均值的代价)
TM_CCORR_NORMED不做均值中心化,亮背景上处处高分;- 跨尺度直接比原始分数偏向小模板 → A2 改用峰显著度
(max-median)/std; - A2 在 8/10 样本上候选位置合理,但 41ac 出现多峰分歧—— 后来证明这些 0.7 的高分全是小尺度纹理巧合,方向本身是错的。
B:SIFT(意外价值)
- 平面图形缺少纹理,大部分样本匹配不足;
- 但在纹理丰富的 4db9、d7a7 上给出亚像素级真值 (scale≈1.00、rot≈0°),成为后续所有方法的交叉验证锚点。
C:旋转感知 NCC(两次评分救不了错误的候选)
- 旋转响应曲线本身有效(真目标 rot≈0),但候选来自 NCC 粗扫, 粗扫错了旋转阶段无法挽回。
D/E:倒角与覆盖率(尺度公平性问题)
- 覆盖率在小尺度上饱和到 1.0(背景纹理到处有边);
- 倒角均值在密纹理区反而更低 → 必须限制尺度范围并加方向约束。
F/G/H/I/J/K:分割路线的共同困境
- Canny 轮廓易把地面/山脉合并成超大块(F);
- 固定/自适应暗度阈值对每张照片都不同(G/I/J);
- K 的教训:
TM_CCORR_NORMED带 mask 在边界产生 inf 值, 需要手写 filter2D 掩码 NCC(后来用于决定性实验)。
转折点实验(值得复用的诊断手段)
- 掩码 NCC 全图扫描(手写 filter2D 实现,无 inf 问题): 对 41ac/444d 在尺度 0.9–1.1 全图扫描,最高分仅 0.56/0.46 → 证明 mark RGB 与洞内容无像素关系,RGB 路线全部判死。
- 闭合轮廓 + matchShapes(444d 上): Canny 闭合轮廓直接命中两个洞,Hu≈0.003,左洞 rot=-0.6°、右洞 -15.5° → 证明"轮廓 + 尺度 + 旋转"路线成立,且给出 444d 独立真值。
- 三特征分离度测量: 洞:内部边缘密度 ≤0.1、亮度差 -40~-104; 草地误检:密度 0.36、亮度差 +6 → 直接给出 L 的特征权重依据。
调试中踩过的具体坑
np.flatnonzero对 2D 数组返回一维扁平索引,取坐标应用np.argwhere;filter2D输出是全尺寸,与手写滑窗比对时要裁剪有效相关区[h//2 : h//2+H-h+1, w//2 : w//2+W-w+1];- 方向桶不能越界重叠:角度已在 [0,180),9 桶直接线性划分即可, 最后一桶再做 wrap 会导致点被双计(表现为均值超上限);
np.arange有浮点累积误差,参与区间比较前先round(s, 3);- Canny 阈值过低(如 26/77)会让 JPEG 噪声淹没"内部平坦"特征。
无 ground truth 时的交叉验证方法
- 找至少一个可用独立方法的样本建立锚点(如 SIFT 能用的纹理丰富样本);
- 用结构性证据补充锚点:候选 bbox 尺寸与 mark alpha bbox 完全一致 (41ac:88×106 vs 88×106);
- 全部候选可视化后人工目检:真目标应落在几何图形上而非纹理上;
- 结果叠加轮廓回贴大图(
src/verify_result.py),观察贴合精度。
线上闭环实验(2026-05-15,CDP 连线)
搭了「hook 采题 → solve → CDP 拖动 → 读 verify」全自动闭环后,暴露出离线 评估覆盖不到的三层问题:
- CDP pressure 指纹:不带
force的鼠标事件 pressure=0(真鼠标恒 0.5), 行为风控 5014 的高危成因。加force:0.5后错误码从 5014 变为 VerifyErr (位置层),证明 pressure 是被采集的判别特征(详见 browser-automation/cdp-drag-force-pressure.md)。 - 错误码分层:5014(行为拒)→ VerifyErr(位置判了但错)→ 5009 (频控,压住一切)→ NotFoundChallengeId(challenge 过期)。判定任何 改动的效果都必须在频控冷却后单次干净尝试,连环重试的 5014/5009 是无效信号。
- 线上题分布漂移:URL 下载的线上题覆盖物是亮白半透明贴片(离线样本 是暗色洞),方法 L 分数从 1-3 恶化到 3.6-7.7,且出现 scale 偏移 (0.85-1.075)与 AMBIGUOUS。翻转 dark_pen 的 bright 模式更差(7+), 说明亮贴片信号弱,尚无可靠增强。
交叉验证手段新增:tip_y 一致性检查——服务端 question.tip_y*2 应等于
solve.y(mark 画布顶 y),两者差得远说明 solve 跑飞,可直接否决该题。
其它工程教训:
- hover 序列里
buttons与button状态矛盾(buttons=0 但 button="left") 会毒化 SDK 拖动注册,verify 根本不发出——按下前保持 button:"none"+buttons:0, 拖动中 buttons:1,释放后恢复; - 每轮点刷新不重建 iframe,iframe 内 XHR hook 持续有效,比 netcap 的 Network 域抓包可靠得多;
- 拖动映射已实测校准:按钮 1:1 带动 mark(UI px),mark 初始相对大图 (0, tip_y*2)(原图坐标),拖动距离 = solve.x × 340/552。
干净账号 CDP 拖动序列(2026-05-15 下午)
换干净账号 + 随机凭证触发后连续 4 次实验(拖动时长依次 3s→5-10s):
| # | 距上次 | score | solve x | SDK位移 | 结果 |
|---|---|---|---|---|---|
| 1 | 冷启动 | 2.450 | 263 | 202* | VerifyErr |
| 2 | +2min | 2.610 | 329 | 202.0/202.6 | VerifyErr |
| 3 | +6min | 2.731 | 261 | 160.0/160.8 | 5014 过快 |
| 4 | +6min | 2.368 | 188 | 115.0/115.8 | 5014 过快(拖动已 5-10s) |
*第 1 次未量位移。关键读数:
- SDK 实际位移与期望差 ≤0.8px —— CDP→SDK→DOM 映射无损;
- solve 位置经 IoU 亚像素精修确认 ±1px —— 坐标换算无误;
- 第 1-2 拖 VerifyErr(位置判定执行)、第 3-4 拖 5014(行为分累积后 位置判定被跳过)—— 与老账号序列模式一致;
- 5-10s 慢拖不能消除 5014 → 行为分按账号+指纹累积,非单次拖动速度。
附带:真人对照(同环境)——老账号手拖 3 次全败(超时/过快/网络差), 换新账号手拖 1 次即过。账号被标记后连真人都过不了。
flatness 贴片特征发现(2026-05-15 傍晚,线上定位瓶颈突破)
live_145400 拖动失败后的归因链:SDK 位移完美 → IoU 亚像素验证 solve 位置 ±1px → 仍 VerifyErr。逐题深挖发现该题 chamfer 精修分 2.655 的最优候选是 雪枝纹理假峰,IoU 实证真缺口在 (412,68)(refined 反而 8.349)—— 边界/纹理特征在亮贴片雪景题上彻底失灵,此前所有 VerifyErr 均由此贡献。
依次否证的特征:masked NCC(离线 0/10 命中)、内部纹理 zNCC(真缺口与 假峰都 ≈0,贴片重绘了内部纹理)、亮度抬升 lift(真缺口 lift 为负)。
有效特征:flatness = 贴片内灰度 std / 环形灰度 std。白半透明贴片把 盖住的背景"贴平",真缺口 0.04–0.28,假峰 0.49–0.67。向量化实现 (filter2D 算窗口均值/方差)后每尺度 ~0.5s。
接入 solve_pair(tip_y 提供时优先 flatness,命中判 OK)+ 修复两处坐标系 bug(tip_y 锚定 canvas=bbox−bbox_y0×s;band 中心同向)后回归:
| 集合 | 修复前 | 修复后 |
|---|---|---|
| 离线 10(无 tip_y,chamfer 路径) | 9/10 | 9/10(不变) |
| 线上 batch 8(tip_y) | 7 OK + 1 AMBIGUOUS | 8/8 OK(全 flatness 命中 6 道) |
| live_145400(GT IoU 实证) | 偏 163px | 差 (5,4)px |
| live_143018(此前跑飞题) | 偏 240px | flatness 命中 (327,187) |
另修复:SCALES 网格下限 0.85→0.70(线上目标尺度可低至 0.775)、拖动时长 5–10s(用户要求,真人观测 4–8s)、随机号触发换号重试(部分号段仅支持 验证码登录,提交被拒后自动换号)。
真人 GT 校准与 solve 修复(2026-05-15 傍晚,决定性)
用户配合做了 5 次手拖校准(轨迹录制器实时快照),拿到 3 个位置真值:
| 题 | solve(修复前) | 真人 GT | 偏差 | 用户结果 |
|---|---|---|---|---|
| live_165204 | chamfer 179 | 177 | +2 | PASS |
| live_172458 | flatness 433 (s=0.7) | 388 | +45 | 网络差(位置对) |
| live_173321 | flatness 209 (s=0.825) | 391 | -182 | 网络差(位置对) |
两条铁律浮出水面:
- flatness 小尺度匹配(s<0.8)全是假阳性——吸到天空/贴片内部等大平坦区; 真目标尺度集中在 0.9–1.075;
- 之前的交叉验证会被弱 chamfer 候选"掩护"(假 flatness 恰好靠近一个 同样是假的 s=0.7 chamfer 候选,两者互相印证通过)。
修复(FLAT_S_MIN=0.8 + 交叉验证只与 refined 前 2 强候选比)后:
3/3 真人 GT 命中(dx=0/0/2),离线 9/10 保持。
输入层排除记录
- CDP 合成指纹:用 xdotool(OS 硬件级鼠标,浏览器无法区分真人)拖 solve 位置 → 仍 VerifyErr → 排除 CDP Input 管线指纹假说;
- 事件指纹:影子记录器对比真人/CDP 的 pressure(0.5)、movementX/Y(非零)、 coalesced、间隔分布——JS 可见属性全部一致;
- 时序积压:绝对时间轴调度修复(旧代码尾部有 1s 空洞 + 0 位移 move);
- 轨迹机械化:
enrich_replay.py把 18 点重放加密为 60Hz 样条流 (Catmull-Rom + 速度衰减微抖动)。
结论:之前所有失败案例事后看都是位置错(flatness 假阳性),不存在独立的 "输入指纹拒"。位置修复后待验证:硬件拖动 + 正确位置能否 PASS(后台循环 进行中,冷却是服务端 5014 强制的 15 分钟窗口)。
换号零等待 + 会话预热 + 时长修复(2026-05-16 上午)
用户确认:触发限流时清 cookie 即可解除(rotate_loop.py reload 前先
Network.clearBrowserCookies,再 Page.navigate 回 douyin——清 cookie 后
session 失效会跳页,reload 会把页面带到空标签)。
三项修复后 20 分钟冷却 + 预热 + 干净单拖 → 仍 VerifyErr:
- 触发拒绝正则误报:
/验证码登录/会匹配登录 tab 自身文字,导致每次 提交都被判"被拒"换号。改为/仅支持验证码|不存在|格式有误|频繁|繁忙|错误/; - up 前终点停顿丢失:真人 PASS 轨迹 up 前 dt≈0.7s(终点对齐),
旧代码固定
sleep(0.15),拖动总时长 2.6s 被压成 1.9s。改为sleep(max(pts[-1].dt, 0.15));跳过 0 位移步时也累加t_plan(时间轴连续); - 会话预热:真人 down 前有 307s 页面活动,我们只有 11s。新增
_warmup_session()(触发验证码之前游走 45-75s——放在采题后会把 challenge 等过期,NotFoundChallengeId 实测)。
会话轨迹硬数据(真人 vs xdotool)
| 指标 | 真人 PASS | xdotool(修复后) |
|---|---|---|
| 拖动 | 109px / 2.60s / 17 move | 位移精确 / 2.6s / 17 move |
| move 间隔 | 20/83/266 ms | 完全一致(同一轨迹重放) |
| down 前 | 70 事件 / 307s | 预热后 ≈30 事件 / 60s |
| up 后 | 11 事件,1ms | 4 事件,500ms |
| y 振幅 | 4px | 4px |
悬而未决
- VerifyErr 根因:位置小偏差(视觉确证 live_191109 solve 正确, live_135654 块内 diamond 略偏)与行为分耦合,现有真值无法拆分;
- 真人 GT 采集效率低(challenge 约 1 分钟过期 + 用户主观对齐误差可达 84px——live_190742 两次自拖差 84px),可信 GT 只有 live_165204 PASS;
- 偶发"拖动不跟手"(鼠标事件完整、SDK 位移 15-100px 戛然而止)无法复现规律。
尺度精修与 VerifyErr 收尾(2026-09-15 深夜)
社区情报核查(VerifyErr 语义确认)
多来源交叉(52pojie 滑块纯算帖、cnblogs 拉灯 s_v_web_id、yoyo1216 playwright、 TikTok-Captcha-Solver、jishuzhan 轨迹原理):
- VerifyErr = 滑块缺口距离错误(位置层);VerifyModelErr 才是轨迹被模型拒
- 换算系数 340/552(≈0.616)为社区共识;部分实现带 ±5px UI 修正(方向互相矛盾, 均无严格论证,source_check 两次判 missing-evidence)
- TikTok-Captcha-Solver:reply.x 按原图 552 系上报(modified_img_width=552), y 每点恒等于 tip_y;CSDN 150612271 实测样本 reply x∈[236,428](modified_img_width=340 但 x 超 340)→ x 上报的确实是另一(原图)尺度
- jishuzhan 轨迹原理:滑块起始 x∈[20,84.05]、y∈[281,325];进页面鼠标轨迹与 滑动轨迹是两组;AI 生成的"四边进入+起始范围"不合规轨迹会被拒
插桩:SDK 加密前明文(JSON.stringify hook)
hook JSON.stringify,捕获含 "mode"/relative_time 的长串。抓到的是 SDK 日志
上报(h5_result / h5_show_picture),非 captchaBody;确认字段:duration(展示→
可交互时长,不是拖动时长)、dragType: "btn"、is_decision、canvas_hash、stage。
指针事件流级对比(真人 vs xdotool,iframe 内 probe)
| 特征 | 真人 | xdotool 60Hz |
|---|---|---|
| coalescedEvents.length | 1 为主(663/682),少量 2 | 1 |
| pointerType / pressure | mouse / 0.5 | mouse / 0.5 |
| move 间隔 | 中位 34ms(≈30Hz),含 0ms 同帧双事件 | 16.7ms |
| 坐标小数 | 无(全整数) | 无 |
| y 振幅 | ±9px | ±4px |
输入层无决定性差异;coalesced/pressure/pointerType/时间戳全部排除。
「拖动不跟手」指标作废
post_pos 在 mouseup 后 0.5-2s 读取,VerifyErr 后 SDK 立即重置滑块—— 读到的 0.0/22.9px 是重置动画中间态。__dragTrace 显示鼠标事件流在全部案例中 完整(53-57 move,位移与期望一致)。此前的"SDK 位移截断"是误读。
尺度精修(src/method_l_shape.py,本轮核心修复)
SCALES 网格步长 0.025,真尺度落在步间时轮廓对齐可偏 5-15px(VerifyErr 量级)。 对入选 OK 候选在 s±0.0375(步长 0.0125)重扫 + ±6px 窗口局部最优 + rotation_scan, 取 refined 最低:
- 离线回归 9/10 保持;精修值出现在网格外(s=0.988/1.038/1.012)证明生效
- GT live_165204:179 → 176(真人 177,Δ −2 → −1)
- 机器 PASS #2:live_160548,x=255,s=1.0375(精修值),157.1px 18 步重放, SDK 155/157.1 跟手,toast「验证成功,正在为您登录」
- VerifyErr×2(live_155342/161204):solve 分数不差(3.3/2.8)但视觉混合不像 —— 是候选选错(s=0.7 网格下边界假峰等),不是差几 px
测试脚手架教训:离线验证必须传 4 通道 mark(IMREAD_UNCHANGED);丢 alpha 会 退化成整方块边界 → score=nan + 左边缘假候选(nan 参与 min(key=refined) 比较行为未定义)。
结论与下一步
- VerifyErr 主要由候选选择错误贡献;尺度精修解决其中"真尺度落在步间"的部分, 并立即产生一次机器 PASS;
- 剩余 VerifyErr 的两题 solve 视觉都不像 → 提升方向是多候选质量评估/换题 (视觉不像宁可 refresh),而非继续调拖动;
- 单号 PASS 后 60s 再战立即 VerifyErr:同一账号短间隔第二次挑战行为分不足, 需要更长冷却或换号。
live_162840 复盘(2026-09-15 深夜,用户视觉确证选错)
精修后第 4 次线上(VerifyErr):x=334 s=1.0375 score=3.23 共识题, 用户观察拖动终点「视觉确实没对齐」。事后穷举定位真缺口:
- NCC 峰 (309,128) NCC=0.26、亮差法 (102,139)、平滑法 (460,232)、 白线 tophat 三处 —— 无一与 diamond 贴片视觉吻合(该题雪地本身极亮, 白贴片对比度崩塌,所有纹理/亮度/平滑特征全灭)
- 对照 PASS 题 live_160548:目标处有明显白色 diamond 描边可辨
工程结论:solve 分数(2.8-4.4 区间)与位置正确性零相关——PASS 与 FAIL 分数同分布。雪景+白贴片题上缺口的可辨识特征在 HSV/梯度域不显著, 单帧图像算法定位此题无解。可行方向:
- 采题侧预检(和 PASS 题同分布的图可先 filter:背景过亮/过白的题 refresh 掉);
- 多帧一致性(refresh 后同题不同噪);
- 服务端验证式试探(成本高)。
ddddocr 评测(2026-09-15 深夜,用户建议)
ddddocr 1.6.1(slide_match,simple_target=True)对抖音贴图题全灭:
| 题 | GT | black 底 | white 底 |
|---|---|---|---|
| live_164551 | 309 | 333 (+24, conf .28) | 369 (+60, conf .62) |
| live_165204 | 179 | 245 (+66) | 234 (+55) |
| live_172458 | 388 | 314 (-74) | 133 (-255) |
| live_173321 | 391 | 446 (+55) | 441 (+50) |
- 误差 +24 至 -255px;black/white 两种 alpha 预处理给出完全不同的答案(不稳定)
- 原因:slide_match 为「窄长滑块 + 挖空缺口」设计;抖音 mark 是约 100px 大块 贴图、缺口是半透明白贴片(非挖空),模板纹理关系不成立
- 结论:ddddocr 不适用于该场景,维持方向感知 chamfer 路线
TV 亚像素精修(2026-09-15 深夜,自研方案终局)
原理:缺口渲染 = bg×(1−a)+white×a(半透明白贴片)。给定候选位置,用 mark 的 alpha 反推 bg(=(pixel−white·a)/(1−a)),反推结果的总变差(TV)在正确位置最低 (反推出的背景应是自然图像)。与 chamfer z 分数相加(λ=1.0),±5px 窗口联合取谷。
标定(4 样本,bbox 系真值):
| 题 | 真值 | 精修前 Δ | 精修后 Δ |
|---|---|---|---|
| live_164551 | 309 | +1 | 0 |
| live_165204 | 177 | −1 | 0 |
| live_172458 | 388 | −1 | +1 |
| live_173321 | 391 | 0 | 0 |
离线 10 题回归 9/10 保持(TV 保护逻辑:谷值信噪 <0.5σ 时跳过,防噪声扰动)。
同日上线:live_172415(=155342 同题二出),solve x=395 未被 TV 移动 (comb 谷在原位),用户目检确认「这个很准」。仍 VerifyErr。 修正认知:post_pos 位移读数只在 PASS 时可信(VerifyErr 后 SDK 立即重置, 读到的是重置中间态——172415 读到 231.8/243.3「截断」实为重置,非不跟手)。
VerifyErr 尾案:位置准 + VerifyErr 并存 → 服务端容差可能比预想更紧(±2px 级), 或存在尚未观测到的判据(行为分残留 / mark 与服务端渲染的 ±2px 源差异)。
候选级 TV 精修定稿(2026-09-15 深夜,第 2 次机器 PASS)
窗口级 TV(仅微调 ±5px)救不了跨候选错选(live_173634:错选 (117,120) s=1.1 refined=3.17,真位置 (271,127) s=0.95 refined=3.26 —— refined 仅差 0.09)。 升级为候选级:对 refined 全部候选各在 ±5px 窗口找 TV 谷,z(chamfer)+z(TV) 联合 重排跨候选选优。两次踩坑后定稿:
- 显著性保护:切候选要求新候选 TV 比原 target 低 ≥1σ (165204 PASS 题被 TV 拉走 +7px 的教训),否则保持原位;
- target 匹配用 ≤2px 容差(尺度精修后 target.x/y 与 enriched 条目不再严格相等);
- TV 评估覆盖全部 TOP_K 候选(target 可能排在第 5 位,top_n=3 会漏)。
标定 5/5 全中:
| 题 | 真值 | 精修后 Δ |
|---|---|---|
| live_164551 | 309 | 0 |
| live_165204 | 177 | −1 |
| live_172458 | 388 | −1 |
| live_173321 | 391 | 0 |
| live_173634 | 270 | −4 |
离线 10 题回归 9/10 保持。
第 2 次机器 PASS:live_182311,x=354 s=0.9875,218px 18 步重放, up 后 iframe 立即销毁(POST 读数 Session not found = PASS 特征), toast「验证成功,正在为你登录」。
当日统计:solve 误差从 ±5px 压到 ≤4px(5 个 GT 全中),线上 PASS 2 次。
稳定性验证批次(2026-09-16 上午,λ=0.5 定稿后)
190244 实测逼出关键修正:λ=1.0 时 TV 把该题从候选1(离 GT 6px)拉到 -71px 崩坏; λ 扫描 {0.3/0.5/0.7/1.0} × 6 GT:λ≤0.7 全中,λ=1.0 崩。定稿 λ=0.5 (保留跨候选 TV 判别力 173634,不淹没 chamfer)。离线 9/10 保持。
线上批次(同一浏览器指纹连续高频测试):
| 题 | solve | 拖动 | 结果 |
|---|---|---|---|
| live_182311 | x=354 s=0.9875 | 218px 跟手 | PASS(toast 确证) |
| live_185845 | x=279 | 171.8 跟手 | 5014 行为分 |
| live_190244 | x=94(TV 拉偏) | 57.9 | VerifyErr(GT 证实应 165,λ 修正后离线命中) |
| live_191721 | x=151 s=0.9875 | 93.0 跟手 | VerifyErr(用户确认位置准) |
| live_094926 | x=166 | 102.2 跟手 | 5014 |
| live_101021 | x=223 s=1.05 | 137.4 跟手 | 5014 |
| live_101724 | x=209 | 128.7 跟手 | 5014("网络环境较差"文案变体) |
结论:
- 识别层收敛:6/6 用户 GT 全中,拖动全部跟手(≤1.4px);
- 5014 是当日高频测试累积的行为分,与识别无关(连真人都会被标记账号拒);
- VerifyErr 尾案:位置准仍拒(172415/191721),疑服务端容差 ±2px 级或 环境级判据——需要跨天/换环境验证。