2D桌球瞄准辅助开发

我小时候非常喜欢玩QQ桌球,最近闲来无事,就想做一个桌球的辅助瞄准器。初步的想法是,在不直接对桌球程序做手脚的情况下进行开发,那么自然而然地想到先对球台截图,利用图片识别获取每个球位置。对于球台和球洞,由于他俩的大小、位置是固定的,所以直接根据截图手动算出球台大小、球洞坐标。

之后根据这些信息,利用一下几何关系就能算出击球点,在将鼠标的位置设置到击球点处,就完成了一次击球的自动瞄准。下面进入正题...

源码

本文相关代码链接:🔗Billiard_Helper

编程环境准备

由于此前一直在用VSCode进行C++开发,所以比较基础的环境搭建并不在这阐述,以后有空会发一篇比较全面的教程文。

OpenCV安装

在Windows下的C++开发,我一般是用Vcpkg来对packages进行管理,好处就是省去手动到package所在的官网下载源码、配置、编译、设置环境变量等等杂七杂八的事项。但有点不足的是Vcpkg上的package并不一定是最新版本,同时Vcpkg上的package编译配置文件有可能是由社区编写,而不是package的作者来编写,因此有可能会出现一些奇怪的问题。但我们这里要用OpenCV,显然这种热门的项目一般是经得住考验的。安装OpenCV命令如下,如果没有梯子,下载很有可能会因为墙的原因而卡住。

cd vcpkg
git pull
vcpkg.exe install opencv:x64-windows

VScode下项目目录设置

首先创建一个项目的文件夹

mkdir billiard_helper
# 使用VScode打开文件夹
code billiard_helper

在进行实际编码前,还要配置一些东西,方便之后的调试。在项目文件夹下,新建一个文件./.vscode/settings.json,并为CMake Tools插件添加一条配置项

{
        "cmake.configureArgs": [
        "-DCMAKE_TOOLCHAIN_FILE=E:/workspace/vcpkg/scripts/buildsystems/vcpkg.cmake"
    ]
}

作用是将Vcpkg管理包的信息给到CMake,方便CMake查找packages。

在VSCode按下F5,之后选择C++,在.vscode下会出现新的一个文件launch.json,将"program"的值修改为"${command:cmake.launchTargetPath}",之后就可以通过CMake Tools插件来编译运行

CMakeLists.txt

在目录下创建CMakeLists.txt,我们需要使用到OpenCV,所以在第6行中表明需要找到OpenCV,且只要其中两个模块

make_minimum_required(VERSION 3.15.0)
project(billiard_helper VERSION 1.0.0)

set(CMAKE_CXX_STANDARD 20)

find_package(OpenCV REQUIRED COMPONENTS core imgcodecs)

完成之后,就可以正式进入码代码的环境了。

代码部分

在敲代码前,要先明确需求,分解需求。脑子里的第一想法是,让程序帮我瞄准桌球。瞄准桌球的基本思想如下

我们期望白球击打黑球后往黑色箭头方向运动,那么白球与黑球碰撞的位置如图,此时白球的中心就是我们鼠标需要瞄准的位置。假设黑球中心为$p_b$,黑色箭头方向单位向量为$\vec{v}$,球的半径为$r$,则瞄准位置应为$p_b - r\vec{v}$。球的大小是固定的,可以很容易测出来,而球的位置是变化的,因此我们需要的信息有

  1. 彩球的位置
  2. 球台的截图
  3. 彩球运动的目标点
  4. 根据鼠标的位置,判断需要进哪个球洞
  5. 有时需要以球击球,因此目标点不一定是球洞,因此需要鼠标来指定目标点
  6. 球洞的位置(手工测量)

分析可以得出,我们程序需要三个部分

  1. 持有球台的全部信息,用几何关系计算瞄准位置
  2. 截图、鼠标、键盘操作部分,与Win32API相关
  3. 根据截图来识别球的位置,即OpenCV处理部分

首先在目录下创建如下文件

.
├── Application.cpp
├── Application.hpp
├── BilliardHelper.cpp
├── BilliardHelper.hpp
├── CMakeLists.txt            (已经创建)
├── ImageAnalyser.cpp
├── ImageAnalyser.hpp
├── main.cpp
├── README.md
└── Vector.hpp

其中CMakeLists.txt中添加如下信息

# 算法部分
add_library(BilliardHelper BilliardHelper.cpp ImageAnalyser.cpp)
target_include_directories(BilliardHelper PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(BilliardHelper PRIVATE opencv_core opencv_imgcodecs)

# 主程序部分,Win32相关
add_executable(billiard_helper main.cpp Application.cpp)
target_link_libraries(billiard_helper PRIVATE BilliardHelper Winmm)
target_compile_definitions(billiard_helper PRIVATE UNICODE)

此时我们的架子基本就搭好了,之后就是在各个部分实现代码。

BilliardHelper

在BilliardHelper中我们需要实现如下功能

// BilliardHlper.hpp
class BilliardHelper {
public:
    void UpdateBalls(const std::vector<Vec2>& ballsPosition);

    // Get the hitting point that can let the ball into the hole
    std::array<Vec2, 2> GetHittingPoint(const Vec2& cursor) const;
    // Get the hitting point that can let the ball pass the target point.
    std::array<Vec2, 2> GetHittingPoint(const Vec2& cursor, const Vec2& targetPoint) const;
};

其中第一个GetHittingPoint(...)用于击球进洞功能,第二个GetHittingPoint(...)是用于以球击球功能,即连续调用此函数来找到最终的瞄准点。两个函数均返回瞄准点和目标点。代码实现部分如下

// BilliardHelper.cpp
std::array<Vec2, 2> BilliardHelper::GetHittingPoint(const Vec2& cursor) const {
    auto pow2 = [](double x) { return x * x; };
    auto ball = FindBall(cursor);

    // Find hole
    size_t targetHole   = 0;
    double minDistance2 = std::numeric_limits<double>::max();
    for (size_t i = 0; i < m_Holes.size(); i++) {
        Vec2 a = m_Holes[i] - ball, b = cursor - ball;
        if (dot(a, b) > 0) continue;
        double distance2 = dot(b, b) - pow2(dot(a, b) / a.norm());
        if (distance2 < minDistance2) {
            minDistance2 = distance2;
            targetHole   = i;
        }
    }

    return GetHittingPoint(cursor, m_Holes[targetHole]);
}

基本思想为,找到鼠标最近的彩球,这个就是我们想要击打的球,然后根据鼠标位置来判断我们此时想要打的是哪个洞。关键在于如何找到我们想要打的洞。示意图如下


由于我们鼠标位置指明想要打的洞,那么鼠标应该与彩球和洞所连直线最近,如图中L2白线最短。同时鼠标与彩球连线与彩球和洞所连直线夹角大于90度(保证了方向)也就是内积要小于0,如图中粉线与灰线夹角。之后再调用第二个函数GetHittingPoint(...)即可。

第二个函数比较简单,可自行查看源码。

Application

Application.{hpp,cpp}主要用Win32API来操作系统相关部分,如获取桌球球台的截图,获取鼠标位置,设置鼠标位置,设置功能热键,此处的难点在于获取桌球球台的窗口句柄hWnd,方法如下

// Application.cpp
HWND Application::GetDeskWindow() const {
    auto result = FindWindowEx(nullptr, nullptr, L"#32770", nullptr);
    RECT rc;
    GetClientRect(result, &rc);
    return rc.right > 1000 ? result : nullptr;
}

其中#32770为桌球球台的WindowClass。找这个值找到心累,大概步骤就是

  1. 编写一个获取当前鼠标指向窗口句柄的函数
  2. 发现球台的窗口没有标题,所以任务管理器中那个桌球进程的窗口不是实际球台的窗口,因此无法利用窗口标题来获取句柄
  3. 但球台有WindowClass,它的值是#32770,但如果在打开过桌球设置页面,且没退出过游戏时,通过WindowClass来获取句柄,得到的时设置页面窗口的句柄,因此在使用过程中不能打开设置页面。要设置,也只能设置完成后再重新进入房间。
  4. 根据所得窗口句柄,获取它的大小,如果小于宽度1000说明不是桌球球台。
    其实也可以利用热键和鼠标配合进行获取窗口句柄。

得到了窗口句柄,也就能获取球台的截图。将这些信息给到OpenCV,就可以进行识别位置。

这里利用热键来进行瞄准功能触发,具体做法是

  1. 用Win32API注册热键
  2. 热键触发时,调用我们的处理函数
// Application.hpp
class Application{
    ...
    template <typename Func>
    Application& AddCallback(int hotKeyId, Func&& func) {
        m_CallBackFunc.emplace(hotKeyId, std::forward<Func>(func));
        return *this;
    }
    ...
    std::unordered_map<int, std::function<void()>> m_CallBackFunc;
    ...
};

// Application.cpp
...
LRESULT CALLBACK Application::WindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) {
    ...
    switch (message) {
        case WM_HOTKEY: {
            if (pThis->IsBilliardRun()) {
                auto deskWindow = pThis->GetDeskWindow();
                pThis->UpdateCapture(deskWindow);
                RECT rc = {};
                GetWindowRect(deskWindow, &rc);
                SetWindowPos(hWnd, HWND_TOPMOST, rc.left, rc.top,
                             rc.right - rc.left, rc.bottom - rc.top, SWP_SHOWWINDOW);
                if (pThis->m_CallBackFunc.count(wParam) == 1) {
                    pThis->m_CallBackFunc.at(wParam)(); // Run callback function
                }
            }
        }
        ...
    }
    ...
}

这样就很容易在main()中注册我们的回调函数,具体用法如下

//main.cpp
app.AddCallback(1, [&]() -> void {
        // Function 1
   })
    .AddCallback(2, [&]() -> void {
        // Function 2
    });

ImageAnalyser

有了截图我们就可以利用OpenCV来拿到球的坐标,首先需要对图形进行预处理

// ImageAnalyser.cpp
void ImageAnalyser::UpdateDesk(const std::vector<uint8_t>& desk) {
    if (desk.empty()) return;

    m_ImgMat = cv::imdecode(desk, cv::IMREAD_COLOR);
    if (!m_Gray.has_value()) m_Gray = cv::Mat{};

    auto& mat  = std::any_cast<cv::Mat&>(m_ImgMat);
    auto& gray = std::any_cast<cv::Mat&>(m_Gray);

    cv::Mat m1, m2;
    cv::cvtColor(mat, m1, cv::COLOR_BGR2HLS);
    cv::Scalar lower(55, 0, 0);
    cv::Scalar upperb(65, 255, 255);
    cv::inRange(m1, lower, upperb, gray);
}

这里我们利用球台是绿色的,进行颜色过滤。第一步将颜色空间转为HLS,这个更空间方便做颜色处理,然后定球台颜色的上下限,其它区域都会标记为黑色,那么我们的球也就变成了黑球,而球台则变为白色,如下图


之后就可以对球进行识别

// ImageAnalyser.cpp
    auto& mat  = std::any_cast<cv::Mat&>(m_ImgMat);
    auto& gray = std::any_cast<cv::Mat&>(m_Gray);

    std::vector<cv::Vec3f> circles;
    cv::HoughCircles(gray, circles, cv::HOUGH_GRADIENT, 1,
                     20,              // change this value to detect circles with different distances to each other
                     85, 13, 13, 13  // change the last two parameters
                                      // (min_radius & max_radius) to detect larger circles
    );

    auto pow2 = [](auto x) { return x * x; };

    std::vector<Vec2> balls;
    balls.reserve(circles.size());
    Vec2 cueBall = {-1, -1};
    for (size_t i = 0; i < circles.size(); i++) {
        auto     center = Vec2{circles[i][0] + 0.5, circles[i][1] + 0.5};
        cv::Rect rect(center.x - 9, center.y - 9, 18, 18);
        auto     color    = cv::mean(mat(rect));
        auto     distance = std::sqrt(pow2(200 - color[0]) + pow2(200 - color[1]) + pow2(200 - color[2]));
        if (distance < 50) {
            cueBall = center;
        } else
            balls.emplace_back(center);
    }
    if (cueBall)
        balls.emplace_back(cueBall);

    return balls;

这里有四个关键点

  1. 斯诺克的球半径为13个像素
  2. HoughCircles的两个参数是二分法调试出来的
  3. 得到球的中心坐标略微偏左上角,手动对xy分量加0.5
  4. 得到球的坐标,返回到彩色图上得到球的颜色,并计算与白色的颜色距离,距离最近的为白球

控制逻辑部分

有了这些信息,那么我们就可以在main(),编写我们的逻辑

// main.cpp
int CALLBACK WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {
    Application app;
    if (int ret = app.Initialize(); ret != 0) return 1;

    auto&          anaylser = ImageAnalyser::Get();
    BilliardHelper helper;

    app.AddCallback(1, [&]() -> void {
           anaylser.UpdateDesk(app.GetDeskImage());
           helper.UpdateBalls(anaylser.GetBallsPosition());

           auto [hitpoint, target] = helper.GetHittingPoint(app.GetCursor());
           app.MoveCursor(hitpoint).DrawLine(hitpoint, target);
       })
        .AddCallback(2, [&]() -> void {
            anaylser.UpdateDesk(app.GetDeskImage());
            helper.UpdateBalls(anaylser.GetBallsPosition());

            auto hintpoints = helper.GetReflectionPath(app.GetCursor(), 4);
            app.DrawLines(hintpoints);
        });

    while (!app.Quit()) {
        app.Tick();
    }
    return 0;
}

需要注意的是,这里用WinMain替换了main(),同时在CMakeLists.txt中的add_executable()中添加WIN32字段。这个做法是隐藏控制窗口,直接运行程序,但无法在VScode中进行调试。需要调试的话可以改回去。

展示评论