圣遗物评分工具:刻晴办公桌技术整理与总结

本文最后更新于:2022年10月27日 晚上

先丢个项目地址: 刻晴办公桌 ,功能介绍可参考 README 或封面图,有兴趣的玩家可以前往 release 下载最新版本体验。

真正意义上第一个附带 GUI 的桌面应用开发,总感觉需要有一篇技术总结。不过我也不是专业程序员,只是简单学过 C,懂一点面向过程与面向对象的区别,之后用 Python 做过数据分析类研究。要是遇到各种不规范问题,欢迎帮我重构,我就随便写着玩的。

这个工具的需求及想法来源在之前几篇文章里已经介绍得非常清楚了(虽然后续迭代变化/增加了不少需求),本篇主要从技术角度讲讲我是如何作为一个小白将之前的需求实现的。顺带简单聊聊技术之于产品经理这一一直争议不断的话题。

GUI 框架选择

在之前需求调研的时候,基本就确定了需要贴图+OCR 两大核心技术。所以相比于其他圣遗物评分工具的网页版、小程序版,本地应用程序基本坐实。简单查了一些常用的 GUI 框架——Qt、Electron、Flutter 等等,可以说 Web 技术的发展完全渗透到本地应用中。

考虑到自己最近比较熟的是 Python,对于 Web 技术的 JavaScript 又一窍不通,最终选择了 PyQt/PySide 框架。算是 Qt 的 Python 版,接口相对来说还是比较全的,大概可以满足我这个稍稍有点不一样的桌面端应用的需求;而且还有一个 GUI 编辑器,方便后期做做 UI 美化什么的。

需求列表

面对一个看上去很复杂的应用程序,最重要的是把复杂问题拆分成可解决(查阅相关资料)的小问题。所以在简单学习了一番 Pyside 应用案例之后,便开始构思整体应用的开发。

主要的需求/步骤有:

  • 贴图窗口
  • 多窗口
  • 单贴图窗口关闭
  • 全局快捷键,关闭/重置所有贴图
  • 鼠标事件跟踪定位贴图,需游戏内鼠标事件可用
  • 高 DPI 适配
  • 判定窗口是否存在
  • 贴图窗口 UI
  • 主窗口 UI,选择框、搜索功能
  • 截图与识别
  • 分数计算
  • 打包
  • ……

其中有不少需求还是在开发过程中遇到的。有些需求很简单,随便查查文档直接就有对应的方法;有些需求则特别复杂,查遍各种资料也未必能有完全符合要求的结果,便对原需求进行适当调整。

难点与坑

下面就挑几个难点讲讲,不是随便搜搜就有一堆教程的,花了不少时间摸索实现的需求。

贴图窗口与多窗口

用于展示圣遗物评分结果的小贴图,如下图所示。本质上就是一个小窗口+文字显示,所以直接实例化一个窗口就可以。使用 QWidget 建一个新类,然后设置相应的窗口属性(置顶、无边框、隐藏任务栏图标)等等,最后在 MainWindow 里调用这个类即可添加一个贴图窗口。

贴图窗口示例

1
2
3
4
5
6
class PasteWindow(QWidget):
    def __init__(self):
        super().__init__()

        self.setAttribute(Qt.WA_TranslucentBackground)
        self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)

GUI 部分大多还是面向对象的编程思维,一直受制于面向过程的思路,我也是花了一段时间才回忆并适应了各种 Class 内部变量的调用(确实很方便)。

至于多个贴图窗口的实现,根据屏幕尺寸以及背包/角色不同的面板选择,贴图的数量是需要动态管理的。这里简单在 MainWindow 初始化中,就完成所有贴图窗口的初始化,先定义一个窗口组的列表 self.pastes,再根据 row 和 col 确定数量去 append 贴图窗口实例即可。当然因为贴图窗口的定位是根据设备和用户选择写死的,所以在初始化的时候就可以 move 到正确位置(后面有个根据用户选择的是背包还是角色,重新初始化的过程在这里就省略了)。

1
2
3
4
5
self.pastes = []
for i in range(self.row * self.col):
    window = PasteWindow()
    self.pastes.append(window)
    self.pastes[i].move(self.position[i][0] / self.SCALE, self.position[i][1] / self.SCALE)

贴图窗口定位

贴图的定位问题,包括后续的截图定位,都已经超出了一般的窗口内部应用的范畴。所以整体的定位需要一些屏幕坐标系的知识基础——横向 x 轴,纵向 y 轴,左上角为原点。游戏和本程序是独立的,为了使贴图可以正好在对应圣遗物上,以及截图 OCR 时文字区域截取正确,不得不要求游戏窗口定死。

然后通过一些作图工具截图、画图、测量坐标,然后写入程序中。不同屏幕的分辨率会有差异,甚至屏幕的长宽比也会不同,导致游戏内部圣遗物网格的布局存在差异。为了适配这些分辨率,需要用到 win32api 获取相关分辨率信息。长宽比一致的分辨率可以通过简单的数学缩放推广演算,不一致的情况就只能重新截图、画图、测量坐标了。(目前适配了 16:9 分辨率,和 2560*1600 分辨率。)

1
2
3
4
5
6
hDC = win32gui.GetDC(0)
width_r = win32print.GetDeviceCaps(hDC, win32con.DESKTOPHORZRES)
height_r = win32print.GetDeviceCaps(hDC, win32con.DESKTOPVERTRES)
width_s = win32api.GetSystemMetrics(0)
print(width_r, height_r)
SCALE = width_r / width_s

还有需要特别注意的一点,似乎在 pyside6 中默认适配了高 DPI,所以系统缩放会把各种数值进行放大。但是在此程序中,贴图窗口的坐标是真实坐标,无需缩放,因此需要额外计算系统缩放比 SCALE,然后在需要的时候缩小回来。

由于坐标繁多,包含各种分辨率、截图区域、背包和角色两个页面的不同布局。为了更清晰地展示坐标信息,专门把各种坐标数据独立在了 location.py 中,有需要的时候引用此模块的数据即可,后续有新的分辨率适配任务也可在其中快速添加和修改。

识别触发行为

同样,由于触发行为不在游戏主窗口,甚至焦点也不在主窗口中,传统应用内部的鼠标、键盘事件都是无效的,所以自然就想到了系统级的事件监听工具。一番简单的搜索后,采用了 pynput 包进行实现。

不过整体实现的过程还是有点复杂的。首先需要打通外部的事件信号和 pyside 的信号数据,信号连接的基本数据结构由 pyside 的 Signal 负责。

由于我这里使用的鼠标事件,所以需要记录鼠标的坐标。通过 pynput 事件信号监听鼠标点击事件,然后在发生事件的时候,把事件数据传回到主窗口中,并与想要达成的目标(比如开启 OCR 识别并显示贴图弹窗,刷新数据)建立连接。这样就可以在游戏窗口中点点鼠标然后启动相应程序贴上评分结果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
self.manager = OutsideMouseManager()
self.manager.right_click.connect(self.open_new_window)
self.manager.left_click.connect(self.fresh_main_window)

class OutsideMouseManager(QObject):
right_click = Signal(int, int)
left_click = Signal(int, int)
def __init__(self, parent = None):
super().__init__(parent)
self._listener = mouse.Listener(on_click = self._handle_click)
self._listener.start()

def _handle_click(self, x, y, button, pressed):
if button == mouse.Button.right and pressed:
self.right_click.emit(x, y)
if button == mouse.Button.left and pressed:
self.left_click.emit(x, y)

不过当我调试完代码,在正常桌面环境运行都正常的时候,打算打开游戏测试下效果,却发现在游戏窗口中毫无反应。一番调试后发现问题竟是在游戏窗口内,鼠标事件在程序中完全监听不到。然后在这一坑中停滞不前了好一会儿,Google 不到有效的资料。尝试过一些其他的鼠标事件库(pydirectinput 等等),说是可以解决游戏窗口监听不到的问题,实际上并没有效果。

因此一度放弃了使用鼠标事件,而是采用曲线救国的方式:先用键盘/主程序按钮事件唤起一个全屏的遮罩窗口,然后在此遮罩窗口中应用窗口内部鼠标监听。为了避免这一遮罩窗口对游戏内容的遮挡,还需要使用较高的透明度(整体思路和截屏软件比较一致)。最后也确实把这一想法整出来了,就放在源代码 test/app_mask.py 中,有兴趣的读者可以了解一下。

后来意外地在使用其他导出工具时,发现需要管理员权限。于是在管理员模式下打开终端,然后运行程序,发现之前的问题完全得到了解决。也即,无论是运行代码,还是 exe 程序,想要监听游戏窗口内容的鼠标事件,都需要管理员权限。

此外,还有一个小坑,就是《原神》在全屏模式下,其他窗口的置顶操作优先级都不如游戏窗口高,使得贴图窗口虽然启动了,但都在游戏窗口之下,没有实际的作用。因此需要将游戏调整为窗口化模式,这里使用的是 Alt+Enter 快捷键(似乎是 Unity 3D 封装的快捷键),并以此窗口化模式作为屏幕各坐标定位的依据(前面提到的贴图窗口、截图区域定位问题)。

OCR

虽说 OCR 是一个相当古早的技术,市场上应用 OCR 技术的产品多之又多,但我并没有亲自做过 OCR 相关的开发。第一个想到的就是使用成熟的技术——调用 API,貌似许多开源工具也都走了这条路,支持多种 API 混用,自己填写相关 api-key 等等。

实现 API 也非常简单,注册账号、申请 key,然后使用 requests 库进行网络请求即可。不过简单尝试一些 API 后,发现要么 OCR 效果不尽人意,要么申请各 key 非常麻烦(数量有限,价格略贵,不符合开源精神)。如果是我一个人轻度用用也罢,要是有其他人也想一起用就有点头大了。

然后找了一些开源方案。PaddlePaddle 安装报错,一番折腾后也没完全搞定,遂放弃。tesseract 安装即用,又有非常好用的 Python 接口,一行代码即可实现对接。之后又在 GitHub 上找到了最新的中文简体语言包,经过测试识别率还算挺高的,遂使用此开源方案。

1
txt = pytesseract.image_to_string(img, lang = 'chi_sim')

整体来说,OCR 也算是一门比较成熟的技术了,自己强行去造轮子意义不大。开源方案中可能 PaddlePaddle 更有优势,不过自己也没有继续折腾的精力了,能满足自己的需求就是好的解决方案。

完成 OCR 初始识别之后就是一些字符处理工作,需要准确提取圣遗物词条的名称及对应的数值。由于识别结果分行展示,同一词条的数据刚好在一行中,可以单独对每行做分析。通过正则表达式提取中文和数字,最后储存相关数据即可。由于攻击、防御、生命存在百分比和数值差异,还需要考虑数值是否是百分比类型,反正我这里就简单 if-else 大法了,简单直白,对于后续计算评分理解起来也方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
pattern_chinese = '[\u4e00-\u9fa5]+'
pattern_digit = '\d+(\.\d+)?'
line = txt.splitlines()
result = {}
for item in line:
if item != '':
print(item)
try:
# 词条名称
name = re.findall(pattern_chinese, item)
name = name[0]
# 数值
digit = float(re.search(pattern_digit, item).group())
if name == '暴击率':
result['暴击率'] = digit
elif name == '暴击伤害':
result['暴击伤害'] = digit
elif name == '元素精通':
result['元素精通'] = digit
elif name == '攻击力' and '%' in item:
result['攻击力百分比'] = digit
elif name == '攻击力':
result['攻击力'] = digit
elif name == '生命值' and '%' in item:
result['生命值百分比'] = digit
elif name == '生命值':
result['生命值'] = digit
elif name == '防御力' and '%' in item:
result['防御力百分比'] = digit
elif name == '防御力':
result['防御力'] = digit
elif name == '元素充能效率':
result['元素充能效率'] = digit
else:
result[item] = 0
except:
result[item] = 0

然后是在实际使用中遇到的一些识别问题:

  1. tesseract 似乎用到了 LSTM,对于上下文重复出现同样的字符容易出现误识别的情况,比如 11.1%,777 之类的数字等等。
  2. 在游戏界面中角色面板装配圣遗物的背景会飘过各种干扰性的遮挡,tesseract 对于这种遮挡基本没招,会大大提高误识别率。

对于以上两种问题,我也是简单粗暴地处理了一下——对于一些常识别错误的情况,把错误识别的字符替换为原来正确的字符就行。经过几轮校正,虽然误识别还是会发生,但是一般都是遮挡较为严重的特殊情况(这一点确实在技术上很难处理),再重新识别一次一般就没什么问题了。

又因为整体工具对于重新识别的成本很低(重新点下鼠标),也就基本当作没什么大的问题了,若是之后再遇到必定/大概率会识别错误的情况,可以做特殊兼容处理。现在整体识别准确率背包面板 95% 以上,角色面板 60%~70%。

全局快捷键

pyside 支持应用内的热键,但是需要窗口处于激活状态,这对于本程序基本是不适用的。因为多数情况下,用户的焦点都在游戏窗口中,需要特意回到主程序再启动热键显然不那么「快捷」。

吸取了前面窗口外鼠标事件监听的经验,很快想到了使用 pynput 的热键功能。随便找了找 pynput 的热键案例,简单调试了一下便实现了全局热键的功能(其实还是摸索了挺久的)。可以通过自定义按键组合——Ctrl+Shift+Z 关闭所有的贴图窗口(self.reset() 是另外自写的方法)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
window = MainWindow()
window.show()
window.hotkey()

class MainWindow(QMainWindow):
def hotkey(self):
def on_activate():
self.reset()

def for_canonical(f):
return lambda k: f(l.canonical(k))

hotkey = keyboard.HotKey(keyboard.HotKey.parse('<ctrl>+<shift>+z'), on_activate)
l = keyboard.Listener(
on_press = for_canonical(hotkey.press),
on_release = for_canonical(hotkey.release))
l.start()

值得留意的是,需要在程序实例 window 上直接调用 hotkey()方法,并启动相关键盘事件的监听程序。不过 pynput 热键方法内部还是挺复杂的,我也搞不清具体的作用,只知道自己需要修改的热键组合在哪里,以及在 on_activate() 中定义热键的功能。

打包

Python 的打包向来是被诟病的,比如打包后文件巨大。虽然 pyside 有详尽官方的 打包教程 ,不过我依然踩了不少坑,遇到了许多不知道怎么就好了的bug。

这里还是用了最常用的 pyinstaller 打包,由于这个打包工具会把环境里所有的依赖包都塞进去(不管与项目是否有关),所以最好是专门创建一个打包环境,仅安装项目相关的依赖包。新建一个打包环境 python 自带功能就能实现,以下是 Windows 系统进入虚拟环境的实例。

1
2
python3 -m venv packenv
call packenv/scripts/activate.bat

然后就是在虚拟环境里安装必备的包,还有 pyinstaller。pyinstaller 的打包方式也非常简单,直接 pyinstaller app.py 即可,其中 app.py 是整个程序的主程序,其他单文件打包等都可以通过添加相应参数实现,具体的还请参考 pyinstaller 官方文档。

首次打包后会生成 app.spec 与主程序同名的配置文件,大致内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[('src', 'src')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='keqing',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='src/keqing.ico'
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='keqing-0.4.1',
)

配置文件本地保存,修改之后可以直接 pyinstaller app.spec 进行打包,而无需通过命令行记各种参数。这里简单讲一下常用的可修改项:

datas=[('src', 'src')] 可以将程序目录的 src 资源文件夹复制到打包好的程序目录中(默认是空的),解决打包后资源丢失的问题。name='keqing', icon='src/keqing.ico' 可以设置打包好后的 exe 文件名称和图标。name='keqing-0.4.1' 可以管理打包版本,在 dist 目录下生成独立的 keqing-0.4.1 程序目录,有新版本需要重新打包时可以修改。

还有一个问题,由于本程序使用了 tesseract 第三方程序,打包过程是没有把第三方程序打包好的。所以要是用户没有配置好 tesseract 环境,在任何目录下运行不了 tesseract.exe,就会报错了。为了实现打包环境自带 tesseract 环境,需要对第三方包 pytesseract.py 进行魔改。

1
2
# tesseract_cmd = 'tesseract'
tesseract_cmd = getcwd() + '/pytesseract/tesseract.exe'

其实也很简单,找到 tesseract_cmd 相关参数的一行,修改为以上即可,主要含义为重新定位 tesseract.exe 的目录。之后把整个 tesseract 程序都搬到打包生成的程序目录下,注意 tesseract.exe 相对路径无问题,即搬完的 exe 程序需要在 pytesseract 目录中。(貌似 pytesseract.py 写死了 pytesseract 的一级目录,手动修改会报错。)

技术力之于产品经理

兜兜转转终于完成了这么一个小工具的半成品开发,目前项目还有不少问题,大多已在 README 中说明了,一些功能仍在学习和优化中。

最后趁着这个机会聊一聊产品经理和技术力作为总结。首先,虽然在外行看来我写完这个工具还有有些水平的(不少水友直呼大佬😊),不过我自己还是觉得这只不过是皮毛中的皮毛罢了。大多数只是工具的简单拼凑,核心问题并解决不了,而且能写出来的很大原因是 Python 实在太亲民了。

然后是关于技术力这个名词,每个职位都需要最核心的技术力,比如产品需要有需求分析与设计的技术力,程序需要有写代码的技术力,UE/UI 需要有作图的技术力。产品作为整个研发过程交流的中心,至少也需要了解一些其他两种技术力。

有一种说法是产品经理可以完全不懂实现层面的技术,然后专注于需求的解读,有什么问题在需求评审的时候再讨论。我实在难以认同这种说法:一来懂一点实现层面的技术可以实现更有效的沟通,需求评审更容易通过,也不容易被程序员拿捏;二来基础层面的技术理解可以更有效地设计产品,不在天方夜谭的需求上耗费时间,及时了解一些新技术的发展也可以拓宽设计思路;三来纯粹的需求层面的技术力整体还是偏弱的,许多项目内的成员也多多少少有一些需求设计的能力,产品的爆火很难归功于纯粹的需求分析和设计,天时地利人和哪一个不重要。强如乔老爷子同样栽倒在 Lisa 项目上,后来的 Macintosh 也败在生态圈的建立上,乔老爷子也因此被董事会扫地出门。

不过我也不会去深入研究实现方面的技术,而把重心放在自己产品方面的技术力上。比方这个工具,我就懒得去研究性能优化,或者做一个完美的前端,这些都交给真正专业的人士去完成就好。可以的话,我代码都懒得写,不过更多时候代码是一种偷懒技巧,没有人帮忙偷懒,所以还是得自己上。至于自己作为产品的核心竞争力,估计我还得再思考思考。

最后,我是由衷地钦佩技术能力和技术氛围的。组里最佩服的是技术大佬们,与有技术背景的领导沟通起来也更容易;谷歌的工程师文化,还有技术宅拯救世界什么的,还是有一些向往的。


圣遗物评分工具:刻晴办公桌技术整理与总结
https://skeathytomas.github.io/post/圣遗物评分工具:刻晴办公桌技术整理与总结/
作者
Skeathy
发布于
2022年9月24日
许可协议