200字
OpenCV Aim Lab Gridshot 打到22W分 : TSP is all you need
2026-01-18
2026-01-27

OpenCV + Aim Lab Gridshot 打到 22W : TSP is all you need

项目地址:https://github.com/ruali-dev/aimlab-gridshot-with-opencv-python

免责声明:本文仅用于学习与研究演示。自动化工具可能违反游戏条款,请自行承担风险。

序言

之前就一直想做一个用 OpenCV 控制 Aim Lab 打小球的项目:目标足够“干净”,规则足够简单,很适合用传统视觉 + 自动化把整条链路跑通。但一直没时间,考研结束后才抽空把它做出来。

开发过程中我用了 antigravity 辅助写代码(Google 的 Vibe Coding IDE)。整体体验是:我把问题描述清楚,它能很快给出可运行的方案,帮我把卡住的环节先往前推。

这篇文章不打算把重点放在「HSV 阈值怎么调」「WinAPI 怎么调用」这种网上到处都有的内容,我更想讲下我在路径优化这个点上做的一点小巧思


总览.webp

问题定义

Gridshot 更像一个“视觉 → 决策 → 控制”的实时闭环:

  • 识别:屏幕里哪些是目标?中心点在哪?
  • 决策:先打哪个、再打哪个?
  • 控制:鼠标怎么以低延迟、低抖动的方式移动过去并点击?

在这个任务里,“每帧能识别到目标”只是入场券;真正影响分数的,是鼠标每一秒走了多少冤枉路。


架构

整体实现拆成三段:

  1. 捕获:锁定游戏窗口,高速截取 ROI(dxcam + win32gui)
  2. 视觉:HSV 颜色分割 + 轮廓筛选,得到目标中心点
  3. 决策 + 控制:mini-TSP 选访问顺序 + Win32 mouse_event 插值移动并点击

这套结构的好处:三段可以分别计时,瓶颈在哪一眼就知道。

补充一个踩坑:我一开始遇到「鼠标移动距离 ≠ 游戏里准星移动距离」的问题(本质上是灵敏度、加速、分辨率/ROI、以及输入注入方式叠加导致的非线性)。后面我把它拆成两个问题:

  • 先用 ROI offset 把“看到的中心”和“准星中心”对齐
  • 再用 灵敏度标定 把“移动多少像素会让准星走多少距离”标出来

这两步做完,开环控制才能稳定工作。


视觉

我只检测一种颜色目标(青色球体),所以没上深度学习:在这个任务里传统视觉足够稳定,而且深度学习带来的推理延迟/部署复杂度不划算。

经典流程:RGB → HSV → 阈值分割 → 轮廓(contour)→ 过滤 → 中心点。

这里的建议很简单:视觉做到“稳定出点”就停手,把精力留给路径与控制。

def find_targets(self, frame):
        """
        Returns list of tuples: (cx, cy, area)
        """
        if frame is None:
            return [], None

        hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
        mask = cv2.inRange(hsv, config.BALL_COLOR_LOWER, config.BALL_COLOR_UPPER)
        
        # Gridshot is clean, but a small dilate can help solidify the ball shape
        # for better centroid calculation.
        # mask = cv2.dilate(mask, self.kernel, iterations=1)

        contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        
        valid_targets = []
        
        for cnt in contours:
            area = cv2.contourArea(cnt)
            if area < config.MIN_CONTOUR_AREA:
                continue
            
            x, y, w, h = cv2.boundingRect(cnt)
            if h <= 0: continue
            
            ratio = w / h
            if ratio < config.MIN_ASPECT_RATIO or ratio > config.MAX_ASPECT_RATIO:
                continue
            
            M = cv2.moments(cnt)
            if M["m00"] != 0:
                cx = int(M["m10"] / M["m00"])
                cy = int(M["m01"] / M["m00"])
                # Return Area for debug
                valid_targets.append((cx, cy, area))
                
        return valid_targets, mask

TSP

同屏一般 1~3 个球,偶尔更多。很多实现会用“最近邻贪心”:

每次打离鼠标最近的那个目标。

它在多数时候能用,但会出现一个典型问题:局部最优不等于整体最优 —— 你可能每一步都走得最短,但走完整轮并不最短。Gridshot 这种高频场景里,哪怕每轮多走一点点,累积起来就是分数差。

TSP优势.png

这个例子里,贪心因为“先吃最近的 A”,导致最后从 B 去 C 多走了 100 像素;TSP 允许一开始多走一点(S→B 比 S→A 多 63.7),换来最后少走 100,总路程反而更短(约省 36.3,≈3.1%)。

我做的 TSP

我做的是一个“工程版 mini-TSP”:

  • 起点:当前鼠标位置
  • 目标集合:当前帧检测到的所有目标点
  • 做法:枚举所有访问顺序,选欧氏距离总和最小的那条
  • 规模:N≤4 直接暴力枚举(排列数很小),实时性完全够
def solve_tsp_best_next(start_x, start_y, targets):
    """Returns the target that starts the optimal path."""
    n = len(targets)
    if n == 0: return None
    if n == 1: return targets[0]
    
    if n > 4:
        targets.sort(key=lambda t: t[4]) 
        subset = targets[:4]
    else:
        subset = targets

    min_total_dist = float('inf')
    best_first_target = None
    
    for perm in itertools.permutations(subset):
        current_dist = 0
        curr_x, curr_y = start_x, start_y
        for t in perm:
            tx, ty = t[2], t[3]
            dist = math.sqrt((tx - curr_x)**2 + (ty - curr_y)**2)
            current_dist += dist
            curr_x, curr_y = tx, ty
            
        if current_dist < min_total_dist:
            min_total_dist = current_dist
            best_first_target = perm[0]
            
    return best_first_target

性能分析:TSP 提升的是什么

在这个规模里,TSP 的计算开销几乎可以忽略;它的收益主要来自:

  • 减少鼠标移动的总距离 → 减少无效时间 → 提高单位时间击杀数

用一个统一口径来描述收益会更清晰:

  • L_greedy 为贪心策略在某一轮的总移动距离
  • L_tsp 为 TSP 最短路径的总移动距离
  • 距离节省率:(L_greedy - L_tsp) / L_greedy

直观理解:哪怕单轮只省 3%~5%,在 Gridshot 这种“几十秒内重复无数轮”的场景里,累积就是实打实的分数差。

我自己跑下来大概能稳定在 22W 左右。对比我看到的一些实现(不少还是 C++ 版本),在识别精度差不多的前提下,得分差距往往不是“识别更聪明”,而是路径策略更省路:同样 60 秒,你能多打出几轮,就会被直接反映到分数上。


控制

控制上我用 Win32 的 mouse_event 来做移动和点击,并把一次大移动拆成多段插值,避免高 DPI 下抖动/过冲。

class Controller:
    def __init__(self):
        self.user32 = ctypes.windll.user32
        if not self.is_admin():
            print("WARNING: Not running as Admin. Input may fail.")

    def is_admin(self):
        try:
            return ctypes.windll.shell32.IsUserAnAdmin()
        except:
            return False

    def is_key_down(self, key_code):
        return self.user32.GetAsyncKeyState(key_code) & 0x8000

    def move_mouse(self, dx, dy):
        if dx == 0 and dy == 0: return
        self.user32.mouse_event(MOUSEEVENTF_MOVE, int(dx), int(dy), 0, 0)

    def click(self):
        self.user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
        import time
        time.sleep(0.015)  # 15ms hold
        self.user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)

校准

跑之前我做了两件事,保证“看到的目标”和“打到的准星”一致:

ROI offset

tools/roi_calibrator.py 调整 ROI 偏移,让脚本的绿色准星对齐游戏准星,然后把值写回配置。

灵敏度标定

tools/auto_sensitivity_calibrator.py 自动标定 sensitivity multiplier。

详细操作见Github仓库README

补一句真实体验:这部分基本是我提出需求(比如“我需要一个脚本能自动把灵敏度标出来”),antigravity 很快给出一个可以跑的方案,我再根据效果把参数和边界情况补齐。对这种“工程工具脚本”,AI 的确很省时间。


总结

这个项目算是一次小尝试:把「屏幕捕获 → 传统视觉识别 → 路径规划 → 输入控制 → 校准」整条链路跑通,并且把重点放在 TSP 带来的路径长度节省 上。

最后在排行榜上最高打到过全球第四。
成绩.webp

这项目效果看着还挺直观:原理不复杂,但把“实时闭环”里最关键的那一环(路径)捋顺了,分数就上去了。

如果你觉得这个开源项目有帮助,欢迎 B 站三连 / GitHub 点个 Star,也欢迎来交流。

OpenCV Aim Lab Gridshot 打到22W分 : TSP is all you need
作者
若离
发表于
2026-01-18
License
CC BY-NC-SA 4.0

评论