Published on

以游戏作为env的强化学习初试

Authors

王者模拟

1. 腾讯开悟测试

环境搭建

由于开悟平台1分为两个部分,hok_env只能支持在linux,hok_env中的gamecore又只能在windows上运行,因此只能使用windows+wsl2的形式进行使用

首先在windows上下载gamecore,并获取许可文件license.dat,然后在linux上拉取开悟仓库

git clone https://github.com/tencent-ailab/hok_env.git

【拉不下来的可以直接下zip然后unzip,一样的效果】

请将其放在license.dat文件夹下:hok_env_gamecore/gamecore/core_assets

windows上执行官方给出的这个代码

 cd gamecore\bin
 set PATH=%PATH%;..\lib\(该句似乎有问题,其实就是将lib文件夹添加到环境变量PATH种,可以手动添加)
 .\sgame_simulator_remote_zmq.exe .\sgame_simulator.common.conf

注意!第三行的sgame_simulator.common.conf是自己编写的配置文件!放在bin文件夹下和sgame_simulator_remote_zmq.exe同级,官方给出的测试配置文件如下:

{
    "abs_file": "../scene/1V1.abs",
    "core_assets": "../core_assets",
    "game_id": "kaiwu-base-35401-35400-6842-1669963820108111766-217",
    "hero_conf": [
        {
            "hero_id": 199
        },
        {
            "hero_id": 199
        }
    ]
}

输出文件类似如下,默认的输出文件夹为bin文件夹下:

AIOSS_221202-1935_linux_1450111_1450123_1_1_20001_kaiwu-base-35401-35400-6842-1669963820108111766-217.abs
kaiwu-base-35401-35400-6842-1669963820108111766-217.json
kaiwu-base-35401-35400-6842-1669963820108111766-217.stat
kaiwu-base-35401-35400-6842-1669963820108111766-217_detail.stat

命令输出如下,游戏核心已成功启动!

image-20241023224907242

然后就是让windows上的gamecore作为hok_env作为服务器

gamecore-server.exe server --server-address :23432

然后在wsl中,需要下载相关的依赖

## after git clone this repo 
cd hok_env/hok_env
pip install -e .

然后就是运行测试脚本

cd /hok_env/hok/hok1v1/unit_test
python test_env.py

小插曲

中途发现wsl中的测试脚本怎么都无法和windows主机的gamecore服务通信,看了一系列的wsl和宿主机通信的资料后,知道了wsl和宿主机其实是处在一同一个内部网段

image-20241023232503486

如图,同处于172.22.0.0/20这个网段,因此按理来说,wsl和宿主机可以通过这个内部网卡分配的ip进行通信,但是wsl硬是ping不通宿主机,而宿主机却可以ping同wsl,于是强行关闭防火墙

netsh advfirewall set allprofiles state off

发现wsl就可以ping通了,确定是宿主机的防火墙拦住了wsl的入站流量,记得重新开启防火墙

netsh advfirewall set allprofiles state on

此处参考资料:允许wsl通往宿主机的入站

【回到正题】

但是执行脚本依然发现没办法通信,于是经过issue的启发,查看测试脚本,发现了以下代码段

image-20241023233544107

发现里面用到了环境变量,但是文档完全没说!!于是

export GAMECORE_SERVER_ADDR="172.22.160.1:23432"
export AI_SERVER_ADDR="127.0.0.1"
# 172.22.160.1 is my win11 ip address

再执行测试文件就可以了

image-20241023233747843
后续使用、训练和修改配置
  1. windows启动gamecore服务器

    已知可变项:sgame_simulator.common.conf文件

  2. wsl在hok1v1/hok3v3下创建测试单元,可以是一个文件夹下的一些py文件(参照官方给的环境测试unit_test),要注意的是,在每个测试单元下都要创建config.json文件,该配置文件包含protobuf处理部分的子奖励因子和log_level,仅在创建实例时加载HoK1v1,如果不采用Hok1v1.reset的情况下也不会应用任何修改,实例文件如下

    {
      "reward_money": "0.006",
      "reward_exp": "0.006" ,
      "reward_hp_point": "2.0",
      "reward_ep_rate": "0.75",
      "reward_kill": "-0.6",
      "reward_dead": "-1.0",
      "reward_tower_hp_point": "5.0",
      "reward_last_hit": "0.5",
      
      "log_level": "4"
    }
    

    在大多数情况下,log_level 应该设置为4以避免无用的日志信息。只有当您在使用我们的环境时遇到某些错误时,log_level才可能需要较低的值来帮助我们获取有关您遇到的错误的更多信息。

  3. 训练回放

    将abs放到replay_tools/replay下启用回放工具即可

  4. 训练环境返回值,在HoK1v1环境的情况下,返回了八个观察结果:

    • observation,一个 NumPy array,其中包含代理对环境的观察的描述;
    • legal_action,一个array描述当前法律行动的 NumPy 扁平视图;
    • reward,描述上一个float动作所获得的直接奖励的值;
    • doneboolean表示当前游戏是否结束;
    • frame_nointeger描述下一状态的帧号的标量;
    • sub_action_mask,一个DictNumPy 数组,描述顶级操作和不同子操作的依赖关系。
    • model_output_name,是tuple描述控制英雄的模型中定义的输出名称的字符串。
    • game_idstring识别游戏。
    • player_idstring当前英雄的运行时识别ID。
    • req_pbstring当前英雄的识别protobuf信息。

实例格式:

{
    'observation': array([ 0.        , 0.        , 0.        ,  ...      , 0.76190478, 1.        ,
                            0.63846153, 1.        , 0.        , 0.        , 0.        , 0.        ]),
    'legal_action': array([0., 0., 1., 0., 0., 0., 0., ..., 1., 0., 0., 0.,
                           0., 0.]),
    'reward': 0.0,
    'done': False,
    'model_output_name': (b'legal_softmax_0', b'legal_softmax_1', b'legal_softmax_2', b'legal_softmax_3', b'legal_softmax_4', b'legal_softmax_target', b'sample_0', b'sample_1', b'sample_2', b'sample_3', b'sample_4', b'sample_target', b'fc2_value'),
    'game_id': b'192.168.10.2_5225_0_gameid-20210710174208-1625910128709',
    'player_id': 8,
    'frame_no': 9,
    'sub_action_mask': {
        0: array([1., 0., 0., 0., 0., 0.]),
        1: array([1., 0., 0., 0., 0., 0.]),
        ....
        11: array([1., 0., 0., 0., 0., 1.])
    },
    'req_pb':{
        sgame_id: "192.168.10.7_9086_0_20210811225922_148"
        frame_no: 129
        frame_state {
            hero_states {
                player_id: 149
                actor_state { .... }
                skill_state { .... }
                equip_state { .... }
                level: 1
                exp: 0
                ....
            }
            hero_states { .... }
            npc_states { .... }
            ....
        }
    }
},

命令行输出示例

image-20241024003121102

2. 明日方舟Agent思路

分析明日方舟的行动点(方舟的行动不是连续线性的,而是离散的):

  • 放置干员:包括放置位置、放置方向
  • 技能开启:部分技能还需要放置附属物或者需要多次点击
  • 撤退干员

状态

  • 干员血量、技力、职业
  • 敌人数、敌人血量、种类
  • 保护点、出怪点、敌人轨迹
V0.1

模块方面可以采用状态估计模块,如预测敌人动作和灵活性、计算资源使用等,或者采用历史记录模块,以便于在决策时参考之前的成功策略或应对失败的教训

训练方面可以采用基于策略的强化学习方法(如Actor-Critic)该方法可以同时处理图像输入和决策制定。你可以考虑使用Proximal Policy Optimization (PPO) 或者 Deep Deterministic Policy Gradient (DDPG)。

已知的技术栈:

  1. DQN、PPO等技术实现识别和决策同时训练
  2. 考虑多任务学习

已知的工具链

  1. 大漠yolo综合工具标注(似乎可以测试yolo模型、截图标注等)

成功案例

  1. Openai的Dota2 AI(同是MOBA游戏,似乎可以参考一下)
  2. DeepMind的AlphaStar

灵感:

  1. 关于yolo图像识别和游戏交互

  2. 关于基于计算机诗句的自动化脚本框架

V0.2
  1. 使用pyautogui实时截取画面,即每隔几秒就截一张截图发给模型,让模型对截图进行最优行为预测,那么就意味着训练需要一系列截图以及对应的行动点

  2. 使用模仿学习,模仿学习分为行为克隆(BC)、逆强化学习、生成式对抗模仿学习,其中的行为克隆就是监督学习,直接模仿“专家”数据中的数据。但是西瓜味克隆具有很大的局限性,尤其是数据量比较小的时候,具体来说,由于通过 BC 学习得到的策略只是拿小部分专家数据进行训练,因此 BC 只能在专家数据的状态分布下预测得比较准。然而,强化学习面对的是一个 序贯决策 问题,通过 BC 学习得到的策略在和环境交互过程中不可能完全学成最优,只要存在一点偏差,就有可能导致下一个遇到的状态是在专家数据中没有见过的。此时,由于没有在此状态(或者比较相近的状态)下训练过,策略可能就会随机选择一个动作,这会导致下一个状态进一步偏离专家策略遇到的的数据分布。最终,该策略在真实环境下不能得到比较好的效果,这被称为行为克隆的复合误差(compounding error)问题,如图所示。

    image-20241025184918447
    1. 鉴于同时训练一个视觉检测和判断的模型难度过大,决定先采取用pyautogui暂时代替视觉效果,将状态信息从图像直接转变成可用的数据,先理解如何利用这些状态信息
v0.3
  1. 采取pyautogui代替视觉效果效果不是很好,似乎很难识别到敌人,会漏掉很多的信息,因此尝试直接使用DQN算法训练,后面再用PPO算法,比较优劣,且加上卷积神经网络作为图像输入,以5-10帧的图像作为输出,输出【时间戳,操作】序列

  2. 发现了一个PPO训练马里奥的项目,可以参考一下,虽然好像没有涉及图像处理,但可以看看他的处理方式,重点看他对状态的处理,其行为相对简单,只有左右上三个行为,action space较小

  3. 感觉重点是状态observation_space和action_space的定义目前学习了q值的定义,即对某个(状态,行为)的优劣值

    DQN算法的整体逻辑就是类似plus版的贪心算法

    image-20241026205351067

    预测Q值即为Q网络获取当前状态和当前动作去预测该特定动作的Q值,而目标Q值就是Target网络获取下一个状态,去预测在下一个状态下所有动作的最佳Q值

    参考资料:1. Q-Lreaning到DQN

    v0.4
    1. 动作空间分为连续动作空间和离散动作空间,方舟的动作逻辑应该是属于离散动作空间

    2. Di Engine提到了可以使用多重离散空间的表示,多重离散动作空间指的是多维度的的离散动作空间,可以理解为是离散动作空间的n维形式,比如我们每次要执行的动作有n个维度,每个维度都由一个离散动作空间构成,类似放置干员一个动作包括三个小步骤?但是放置干员动作的值包括三个不同的离散值

      import gym
      from gym.spaces import Discrete, MultiDiscrete
      """
      e.g. Nintendo Game Controller
      - Can be conceptualized as 3 discrete action spaces:
          1) Arrow Keys: Discrete 5  - NOOP[0], UP[1], RIGHT[2], DOWN[3], LEFT[4]  - params: min: 0, max: 4
          2) Button A:   Discrete 2  - NOOP[0], Pressed[1] - params: min: 0, max: 1
          3) Button B:   Discrete 2  - NOOP[0], Pressed[1] - params: min: 0, max: 1
      """
      
      # discrete action space env
      env = gym.make('Pong-v4')
      assert env.action_space == Discrete(6)
      # multi discrete action space
      md_space = MultiDiscrete([2, 3])  # 6 = 2 * 3
      
    3. 但是磨菇书提到DQN算法处理连续动作是比较麻烦的的,即一个大动作包含一系列的小动作,如机器人行走包含一系列的关节扭转角度,而方舟的放置干员也是这个类型,而处理连续动作的比较好的算法是演员-评论员算法(A2C算法)

    TODO

    • 定义action_space,现在的思路是封装一个放置干员的函数,模型只需要输出选定哪个干员,选定位置,以及朝向(拖动时间maybe)即可

    • 发现了一个可以简化模型输出的方法: image-20241026222250950

      解码方式:

      image-20241026222318682

      但是直接采取这种唯一动作编码的方法可能会让模型很难去学习到每一个动作的意义,比如他很难去明白85代表着什么,降低了模型的学习能力。

    • opencv使用教程

    py

    **【小插曲】**使用docker部署项目时拉不到dockerhub,添加镜像站就可以了

    vim etc/docker/daemon.json
    

    添加以下镜像站

    {
        "registry-mirrors": [
            "https://docker.m.daocloud.io",
            "https://dockerproxy.com",
            "https://docker.mirrors.ustc.edu.cn",
            "https://docker.nju.edu.cn"
        ]
    }
    

    然后重启reload就可以正常拉取了

    sudo systemctl daemon-reload
    sudo systemctl restart docker
    

3. PyAutoGUI入门

**【小插曲】**由于c盘路径有中文字符导致pip解析不到conda环境地址,因此需要手动指定conda路径才能下载库

conda install pyautogui --prefix "C:\Users\沈再安\.conda\envs\ai_sdk"

后期指定虚拟环境的路径,不设定在c盘上了

conda config --add envs_dirs E:\Anaconda\envs

**【小插曲】**安装torc后出现OSError找不到torch_python.dll,搜索资料发现是python版本冲突,将python版本从3.6提升到3.6.10就好了

conda install python==3.6.10

且意识到,torch是依赖于python版本的,如果直接从3.6的pytorch环境下升级3.7,会导致torch出现问题,例如获取不到cuda等,要么就直接重新安装3.7的torch

  • 基础知识

    屏幕上的位置由X和Y笛卡尔坐标表示。X坐标从左侧的0开始,向右增加。与数学不同,Y坐标从顶部的0开始,向下增加。左上角的像素位于坐标(0, 0)。如果您的屏幕分辨率为 1920 x 1080,则右下角的像素将为(1919, 1079),因为坐标从0开始,而不是1。坐标系如下所示。

    0,0       X increases -->
    +---------------------------+
    |                           | Y increases
    |                           |     |
    |   1920 x 1080 screen      |     |
    |                           |     V
    |                           |
    |                           |
    +---------------------------+ 1919, 1079
    
  • 一般函数
    # 获取当前鼠标位置
    print(pyautogui.position())
    # 获取当前屏幕的分辨率
    print(pyautogui.size())
    # 判断某个坐标是否在屏幕上
    x=10
    y=20
    print(pyautogui.onScreen(x, y)) 
    
  • 鼠标函数

和图像坐标系一样,屏幕左上角的坐标点为(0, 0),X向右增加,Y向下增加。

鼠标移动

# 用num_seconds(秒)将鼠标移动到(x,y)位置
x = 200
y = 100
num_seconds = 1
pyautogui.moveTo(x, y, duration=num_seconds)  

# 用num_seconds(秒)将鼠标从当前位置向右移动xOffset,向下移动yOffset
# 如果duration为0或未指定,则立即移动。
xOffset = 30
yOffset = -50
num_seconds = 0.5
pyautogui.moveRel(xOffset, yOffset, duration=num_seconds) 

鼠标拖动

# 用num_seconds(秒)将鼠标推动到(x,y)位置
# 鼠标拖动是指按下鼠标左键移动鼠标。
x = 200
y = 100
num_seconds= 1
pyautogui.dragTo(x, y, duration=num_seconds)  

# 用num_seconds(秒)将鼠标从当前位置向右拖动xOffset,向下推动yOffset
xOffset = 30
yOffset = -50
num_seconds = 0.5
pyautogui.dragRel(xOffset, yOffset, duration=num_seconds) 

鼠标单击

# 将鼠标移动到(moveToX,moveToY)位置,点击鼠标num_of_clicks次,每次点击间隔secs_between_clicks秒
# button表示单击方式,'left'左键单击,'middle'中键单击,'right'右键单击
moveToX = 500
moveToY = 600
num_of_clicks = 1
secs_between_clicks = 1
pyautogui.click(x=moveToX, y=moveToY, clicks=num_of_clicks, interval=secs_between_clicks, button='left')

所有的鼠标点击都可以用click()完成,但也存在一些函数是为了方便阅读,如下所示。

moveToX = 10
moveToY = 20
# 右键单击
pyautogui.rightClick(x=moveToX + 50, y=moveToY)
# 中键单击
pyautogui.middleClick(x=moveToX + 50, y=moveToY)
# 左键双击
pyautogui.doubleClick(x=moveToX + 50, y=moveToY)
# 左键三击
pyautogui.tripleClick(x=moveToX + 50, y=moveToY)

鼠标滚动

moveToX = 100
moveToY = 200
# 鼠标在当前位置向下滑动100格
# pyautogui.scroll(clicks=-100)
# 鼠标移动到(moveToX,moveToY)位置,然后向上滚动150格
pyautogui.scroll(clicks=150, x=moveToX, y=moveToY)

鼠标移动并按下

# 鼠标移动到(moveToX,moveToY)位置,鼠标左键按下
pyautogui.mouseDown(x=moveToX, y=moveToY, button='left')
# 鼠标移动到(moveToX,moveToY)位置,鼠标右键松开(按下右键的情况下)
pyautogui.mouseUp(x=moveToX, y=moveToY, button='right')
# 鼠标在当前位置,按下中键
pyautogui.mouseDown(button='middle')
  • 截屏
# 截屏返回result对象
result = pyautogui.screenshot()
# result是PIL中的Image对象
print(type(result))
# 保存图像
result.save('result1.jpg')
# 展示图片
#result.show()

# imageFilename参数设置文件保存为止,在截屏前保存图片到本地foo.png文件
# region设置截图区域[x,y,w,h],以(x,y)为左上角顶点,截宽w,高h的区域
result = pyautogui.screenshot(imageFilename='result2.jpg',region=[10,20,100,50])
  • 图像定位

PyAutoGUI提供了多个定位函数。都是从左上角原点开始向右向下搜索截图位置。具体如下:

  • locateOnScreen(image, grayscale=False)

在屏幕中,返回和image图片最类似区域的坐标(left, top, width, height),如果没找到返回None。grayscale设置是否灰度查找。

  • locateCenterOnScreen(image, grayscale=False):

在屏幕中,返回和image图片最类似区域的中心坐标(x, y),如果没找到返回None。

  • locateAllOnScreen(image, grayscale=False)

在屏幕中,返回和image图片所有类似区域的坐标(left, top, width, height)的生成器

  • locate(needleImage, haystackImage, grayscale=False):

在haystackImage中,返回和image图片最类似区域的坐标(left, top, width, height)。

  • locateAll(needleImage, haystackImage, grayscale=False):

在haystackImage中,返回和image图片所有类似区域的坐标(left, top, width, height)的生成器。

官方说在1920x1080屏幕上,screenshot()函数大约需要100毫秒。但实测图像定位需要花费3秒左右,而且常常找不到图片相似区域。可选的confidence关键字参数指定函数在屏幕上定位图像的准确性。如果由于像素差异可忽略不计,函数无法定位图像,调低confidence将提高查找命中结果。但是需要安装OpenCV才能使confidence关键字工作。

图像定位函数基础使用如下:

# 在屏幕返回和result1.jpg图片类似的区域坐标,返回值(左上角x坐标,左上角y坐标,宽度,高度)
# 如果没找到返回None
result = pyautogui.locateOnScreen('result1.jpg')
# 在屏幕返回和result1.jpg图片类似的区域中间位置的XY坐标,confidence返回区域最低置信度
result = pyautogui.locateCenterOnScreen('result1.jpg', confidence=0.9)
# 为查找图片找到的所有位置返回一个生成器
results = pyautogui.locateAllOnScreen('result1.jpg', confidence=0.6)
print(results)
# 打印各组的(左上角x坐标,左上角y坐标,宽度,高度)
for i in results:
    print(i)
# 将结果保存为list
list_result = list(pyautogui.locateAllOnScreen('result1.jpg', confidence=0.6)

# 在haystackImage中,返回和image图片最类似区域的坐标
result = pyautogui.locate(needleImage='result1.jpg', haystackImage='result.jpg', confidence=0.5)
# 在haystackImage中,返回和image图片所有类似区域的坐标(left, top, width, height)
result = pyautogui.locateAll(needleImage='result1.jpg', haystackImage='result.jpg', confidence=0.5)

这些“定位”功能相当昂贵;他们可能需要整整几秒钟的时间才能运行。加速它们的最好方法是传递一个region参数(一个(左、上、宽、高)的4整数元组)来只搜索屏幕的较小区域而不是全屏。但是这个region区域必须比待搜索截图区域大,否则会引发错误。代码如下:

result = pyautogui.locateOnScreen('result1.jpg', region=(0,0, 300, 400))
result = pyautogui.locate(needleImage='result1.jpg', haystackImage='result.jpg', confidence=0.5, region=(0,0, 300, 400))

您可以传递grayscale=True给定位函数以提供轻微的加速(大约30%左右)。这会降低图像和屏幕截图的颜色饱和度,加快定位速度,但可能会导致误报匹配。

result_location = pyautogui.locateOnScreen('result.jpg', grayscale=True,confidence=0.6)
此外要获取截屏某个位置的RGB像素值,可以用PIL中Image对象的getpixel()方法,也可以用PyAutoGUI的pixel()函数。
im = pyautogui.screenshot()
print(im.getpixel((100, 200)))
print(pyautogui.pixel(100, 200))

如果您只需要验证单个像素是否与给定像素匹配,请调用该pixelMatchesColor()函数,并将其表示的颜色的X坐标、Y坐标和RGB元组传递给它:

# 颜色匹配
pyautogui.pixelMatchesColor(100, 200, (255, 255, 255))
# tolerance参数可以指定红、绿、蓝3种颜色误差范围
pyautogui.pixelMatchesColor(100, 200, (248, 250, 245), tolerance=10)

4. 图像处理

对于图像处理,可以考虑图像分割+卷积神经网络/模板匹配识别图像中的数字类别 + 数字拼接,提取例如部署费用、敌人数量等信息,参照

暂时先使用pytesseract库来进行图像数字识别,参照

import cv2
import pytesseract
import re

image = cv2.imread('image.jpg')
gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
_, binary_image = cv2.threshold(gray_image, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
denoised_image = cv2.medianBlur(binary_image, 3)
result = pytesseract.image_to_string(denoised_image, config='--psm 6')
numbers = re.findall(r'\d+', result)

for i, number in enumerate(numbers):
    cv2.putText(image, number, (10, (i+1)*30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

cv2.imshow("Result", image)
cv2.waitKey(0)
cv2.destroyAllWindows()

5. 训练

如果出现了RuntimeError: running_mean should contain 64 elements not 128,就是数据即数量不能整除epoch的值,并且没有设置drop为true,调整数据集数量即可

image-20241114120508927

性能指标:

训练命令(epoch * batch = sum),注意修改data.yaml文件中的路径

yolo detect train data=data.yaml model=yolo11n.pt epochs=64 imgsz=640 batch=8

预测命令

yolo detect predict source=test.jpg model=runs/detect/train3/weights/best.pt

官方文档

display