一、前言?
契机是:在音游群里问了一句,群里的bot怎么查B30?然后大佬回复说最近机器人挂了,查不上了。就自己搓了一个。
虽然不能做到从服务器直接获取打歌数据,但是在手动录入痛苦一下之后还是可以实现B30查询自由的。
最终效果
特地做了个很好康的icon
二、信息摘要
Excel的简单信息处理 VBA的简单应用 Python的Json处理 Python的Excel工作表读取 Python的图片操作
三、正文
1、打歌成绩的录入(Excel公式+VBA)
因为Excel本身就可以很方便地存一些记录和进行简单运算,因此决定直接在Excel完成录入全部工作,后续用Python的模块读取xlsm文件。(xlsm:启用宏的Excel工作簿)
Main工作表用于存储所有歌曲的标题和定数信息
?零、友好地问wiki要一份定数表
是的,目前还没实现自动/一键更新定数表。如果616更新了还得重新干一遍。
好事是,直接复制粘贴就可以了。
一、做一个录入UI
Calc工作表,用于用户填写并导入成绩
?一般录入成绩需要用户提供哪些要素?我打了什么歌?难度等级是什么?分数多少? 进而再推算出本次打歌的有效ptt,再添加到成绩库之中。
直接让我输入歌名,我肯定是不干的。先不说麻不麻烦,Arcaea中有些歌名就不是人能打出来的,而且我也不想录入个成绩还胆战心惊地校对有没有哪里拼错。。因此做了一个简单的搜索宏,输入部分歌名即可返回结果,并且有一定的容错。
搜索算法大致思路:逐个比较字符,相同则继续比对并增加匹配度,不同时扣除匹配度并且判断是否连续5个字符不同 ,若是则中断比对 ,跳到下一个比对目标。一次比对完成后更新匹配度top10的列表。全部比对完成后将匹配度top10返回,并自动将最匹配的歌名填入。
(由于加粗部分的算法设置,绝大多数情况下top10列表里只有一个结果
12.16更新:逐个比对字符并按照一定规则计算匹配度:
完全相同,相似度增加并重置惩罚
大小写模糊后相同,相似度增加
希腊字母模糊后相同,惩罚增加,相似度不变。为了防止搜什么都是那几首霸屏。
模糊后的结果
?
均不满足,惩罚增加,扣除惩罚值相同的相似度。惩罚>7时额外扣除大量相似度。
至于难度等级和分数只能由用户手动输入了。在完成后,Excel通过公式计算单曲ptt。具体的计算在wiki上公开。公式如下:
=MAX(IF(F5>=10000000,HLOOKUP(E5,F2:I3,2,FALSE)+2,IF(F5>=9800000,HLOOKUP(E5,F2:I3,2,FALSE)+1+(F5-9800000)/200000,HLOOKUP(E5,F2:I3,2,FALSE)+(F5-9500000)/300000)),0)
至于搜索按钮和导入按钮,只是两个分配了宏 的圆角矩形罢了。因为宏的存在,文档只能保存为xlsm格式。
一共使用了4个宏,除搜索宏外,还做了导入宏和两个便于维护的一键排序+索引生成。搜索时可根据索引提高效率。
B30工作表,稍后Python会读取这一工作表的数据进行图像生成
?2、获取对应歌曲的曲绘(Python+apk解包)
解包的直接坏处是:会占用几十MB的硬盘,并且更新了之后需要手动加新的曲绘。但是我不想不会做 等它慢慢去网上爬回来我要的曲绘,这一条好处就足够了。
下载Arcaea的apk安装包,后缀改.zip,在songs文件夹下找到了我们需要的所有东西:按照歌曲id 整齐存放的曲绘,和songlist文件。
这里的问题是:歌名不同于歌曲id,需要一番转换。
简单分析,songlist文件是用Json写的,因此可以直接利用。并且 ,它存储了各个歌曲的所有信息 ,包括id和歌名(en翻译就足够了,因为wiki提供的是英语;有些其他语种的歌名用不上)。
因此,在Python中导入json模块并处理之。
这里的file_path变量即songlist的路径,涉及到获取当前运行路径,稍后再说。songlist是无后缀名的,不要把它同文件夹混淆。顺便,末尾别忘了随手关门。
filejson = open(file_path,'r',encoding='utf-8')
songdic=json.load(filejson)
filejson.close()
并且导入openxlrd模块,打开我需要的工作簿(filename变量指向)和工作表B30 。用for循环 遍历cell(i,1)即第1列的单元格,append到一个List上 。如法炮制多定义几个List之后,工作表中我们需要的所有数据就可以在Python中调用了。
book = xlsx.load_workbook(filename)
sheet = book['B30']
Songs=[]
for i in range(2,32):
Songs.append(sheet.cell(i,1).value)
之后,在Json(此处及下文均会使用Json指代前文已经处理好的由songlist生成的字典songdic )中顺序暴力 查找第i个歌名对应的歌曲id并如法炮制地append到另一个List。
如此就完成了歌曲id的获取。稍后要获取曲绘的时候只要稍加修改就可以了,图片的打开需要导入PIL.Image模块。代码预览:
for i in song_index:
if(song_dl[i]):
dlstr='dl_'
else:
dlstr=''
#处理需要下载的歌曲的特殊文件夹名。解包时发现的,616你做得好,做得好啊
if(diff[i].lower()=='byd'):
flstr='3.jpg'
else:
flstr='base.jpg'
#处理byd难度的特殊曲绘
im=Image.open(os.path.join(root_path,'src/songs',dlstr+song_id[i],flstr))#打开对应曲绘
注1:os.path.join(path1,path2,...)可以帮助拼接并生成一条合法路径。
注2:root_path即当前所在路径。
3、开始处理图片
这里我选择了PIL,没有用cv2的原因是,打包出来exe之后cv2模块会报错。。只能忍痛删除,并且把所有背景图都预先处理成2560*1440+高斯模糊。
????????一、先在背景上贴曲绘
大致设想:在一张2560*1440的背景上,每首歌曲占用256*356(纵向多留100px给文字),其中曲绘240*240(留16px的空白),并且稍作修饰以表现难度等级和是否PM的信息。(需要用到的模块:PIL.Image,PIL.ImageFont,PIL.ImageDraw)
其中,背景从某个文件夹(/src/bgs)随机选取一张图片(os模块获取其列表,random模块生成随机数)。至于那么多背景哪里来,我选择解包拿几张CG图。
因此,直接把背景打开到output上,之后的操作全部对output进行。os.listdir(path)可以生成path下所有内容的List,Image.open(path)则打开path指向的图片文件,random.choice(List)可以返回List中的随机一个元素。
bg=os.listdir(os.path.join(root_path,'src/bgs'))
output=Image.open(os.path.join(root_path,'src/bgs',random.choice(bg)))
然后就可以一张张贴曲绘了。由于上文是单变量遍历,因此需要用到简单的取余(%)、整除(//)来算坐标。
for i in song_index:
if(song_dl[i]):
dlstr='dl_'
else:
dlstr=''
#处理需要下载的歌曲的特殊文件夹名。解包时发现的,616你做得好,做得好啊
if(diff[i].lower()=='byd'):
flstr='3.jpg'
else:
flstr='base.jpg'
#处理byd难度的特殊曲绘
im=Image.open(os.path.join(root_path,'src/songs',dlstr+song_id[i],flstr))#打开对应曲绘
im=im.resize((240,240))#调整大小,一个图总共占256*356空间,多出来的留作空白和放数据文字
output.paste(im,(256*(i%10)+8,350+356*(i//10)+8,256*(i%10+1)-8,350+356*(i//10+1)-108))
注1:image.resize((x,y)[,Image.ANTIALIAS])传递的两个参数中,第二个是可选的,第一个是一个二元组 表示缩放结果大小。因此在不强行指定缩放方式的情况下,应当打两层括号 ,例如img.resize((256,256))。否则报错。
注2:image.paste(icon,box[,mask])中的box 可以为只指定左上角的二元组坐标,也可以为同时指定左上、右下角的四元组坐标。在使用四元组坐标的情况下,如果icon的大小不符合box划出来的大小则会直接报错。另外在粘贴PNG图片时 ,需要传第三个参数来传递mask的透明度通道,例如img.paste(myicon,(0,0),myicon)。也可以使用另一个图片文件的透明度通道。
? ? ? ? 二、对每个曲绘稍作修饰
我希望在曲绘的右上角直接显示对应歌曲的难度。并且在曲绘的右侧绘制8px宽的竖条矩形,使用某一个难度的对应色来辅助传递这一信息。另外,已经PM的歌曲就没什么继续推分的必要了我太菜了完全没法理论 ,所以我希望特别标注出来。
解包时,我翻到了我要的素材,全部拷到/src/tags下面。另外对各个难度进行了取色(QQ截图就能直接复制色号),直接写到Python里。因此定义如下:
clr={'byd':(125,21,43),'ftr':(112,47,99),'prs':(139,163,96),'pst':(62,159,183)}
badge={}
for dif in ['pst','prs','ftr','byd']:
tim=Image.open(os.path.join(root_path,'src/tags',dif+'.png'))
tim=tim.resize((73,22))
badge[dif]=tim
两个我都定义成了字典,因为Excel中的数据本来就是如此标级的,一会方便直接用。字典的“append”只要对一个不存在值的key赋值即可。
另一个PM标记也是直接Image.open即可,不再赘述。
因为显示顺序的关系,涂8px的矩形肯定是要先于贴难度tag的。此处用的是image.putpixel(pos,rgb),其中pos是二元组(x,y),rgb是三元组(r,g,b)。
直接进行一个双层循环。因为最外面有个for i in index的大循环,因此直接通过之前存好的List获取某个歌曲的难度。
for x in range(256*(i%10+1)-16,256*(i%10+1)-8):
for y in range(350+356*(i//10)+8,350+356*(i//10+1)-108):
output.putpixel((x,y),clr[diff[i]])
再就是贴tag,仍然是image.paste(),不再赘述
再就是贴PM(如果你PM了,就奖你一朵小P花罢!) ,判断如果这首歌得分超过10,000,000则image.paste(),PM标记是有透明度通道的,所以得传三个参。也是很简单的代码,不赘述了。
? ? ? ? 三、文字信息的添加
再是绘制文字。主要用了三大句子:
font=ImageFont.truetype(font_file, size)
draw = ImageDraw.Draw(img)
draw.text((x,y),text,(r,g,b),font)
提前定义一些字体,包括字体文件(*.ttc,*.ttf)和字号
定义一个ImageDraw对象,告诉它你得在img这个图上涂画
调用ImageDraw的text方法,这个的参数很好懂
其中,字体文件就用解包出来的结算分数同款字体(谢谢光光) ,复制到/src/下
至于text这边的字符串控制可以稍微提一下:如果是熟悉c而不熟悉py的同学,可以像这样格式化输出字符串:
"%s, %.1f?is your score."%(player_name, score)
家的味道。
这样就可以完成所有文字的绘制工作了。
最终使用image.save()来保存图片,详情直接参考其他大佬的博客就行(我参不透) 。
4、一些小细节
使用tkinter.filedialog的对话框来打开文件、指定路径时,会蹦出来一个空的Tk图形窗口。因此加入如下代码:
root = tkinter.Tk()
root.withdraw()#隐藏tk产生的窗口
#主程序
root.destroy()#销毁tk留下的窗口
tk这波是纯对话框工具人。
image.putpixel()在进行很大范围的像素操作时效率很低。(而我偏偏又得对几乎整张图进行暗化
四、后记
整个工程从开工到现在不过三天,且我在开始前对这一块内容一无所知,甚至对Python只有自学得来的一些很浅薄的认知。回过头一看,很多地方都是毛毛糙糙。
1、目前仍然只能待在“实验室”里
因为从头到尾,测试者只有我一个人,而我又规规矩矩地输入着各项数据,所以很多漏洞都不得以出场。例如,难度我如果不是全小写,输入一个“FTR”会怎么样呢?或者干脆拼错?
一方面缺乏对于输入的自动调整来保证程序最大限度地正常运行,另一方面没有报错。假如哪天崩溃了,我连它为什么崩溃可能都无法得知。
至少先去把报错做上吧。
2、优化
因为Arcaea整个曲库也不过几百首,很多算法的性能问题都没有什么表现的机会,全程暴力也能完成任务。另外,也因此我可以放心大胆地定义一堆玩应,可能造成了很大的浪费。
bot快点恢复查ptt啊我就算能算B30难度还能算R10吗录入那么多我要死了
感谢lowiro。
这个工程仅供个人学习交流之用。
五、Python源码
import tkinter.filedialog
import openpyxl as xlsx
import json
from PIL import Image, ImageFont, ImageDraw
import os
import sys
import random
gen_path = os.path.dirname(os.path.realpath(sys.argv[0]))#打包成exe之后,直接使用os.getcwd()或者abspath()都无法正确获取,解决方案如左照抄
clr={'byd':(125,21,43),'ftr':(112,47,99),'prs':(139,163,96),'pst':(62,159,183)}#四个难度的颜色定义,一会方便直接用
#Main()
root = tkinter.Tk()
root.withdraw()#隐藏tk产生的窗口
player=input('Enter your Player Name:')
print('Select a B30 file:')
filename=tkinter.filedialog.askopenfilename(title='选择B30表', filetypes=[('所有文件','.*'),('Excel文档','.xls'),('Excel2003文档','.xls'),('Excel启用宏的工作簿','.xlsm')])
print('filename=%s'%(filename))
print('Determine Output Directory:')
savepath = tkinter.filedialog.askdirectory(title='保存路径选择')
print('savepath=%s'%(savepath))
#获取用户定义的玩家名,Excel工作簿和导出路径并echo
badge={}
for dif in ['pst','prs','ftr','byd']:
tim=Image.open(os.path.join(gen_path,'src/tags',dif+'.png'))
tim=tim.resize((73,22))
badge[dif]=tim
#打开各个难度名对应的图片文件备用,用字典存储
pm=Image.open(os.path.join(gen_path,'src/tags/pm.png'))
#打开PM标记的图片备用
book = xlsx.load_workbook(filename)
sheet = book['B30']
#openpyxl模块开文件,给B30工作表一个变量存着
filejson = open(os.path.join(gen_path,'src/songlist'),'r',encoding='utf-8')
songdic=json.load(filejson)
#打开songlist文件,json模块转字典songdic备用
filejson.close()
#随手关门
font=ImageFont.truetype(os.path.join(gen_path,'src/font.ttf'), 42)
titlefont=ImageFont.truetype(os.path.join(gen_path,'src/font.ttf'), 102)
#字体文件定义,一会在图片上写文字直接调用
bg=os.listdir(os.path.join(gen_path,'src/bgs'))
#背景文件列表获取
Songs=[]
diff=[]
ptt=[]
Sc=[]
#定义了一万个list。留着等后面慢慢append
song_index=[]
song_id=[]#存储各个歌曲对应文件夹位置
song_dl=[]#各个歌曲是否需要下载
avg=0.0
for i in range(2,32):
Songs.append(sheet.cell(i,1).value)
diff.append(sheet.cell(i,2).value)
ptt.append(sheet.cell(i,3).value)
avg=avg+sheet.cell(i,3).value
Sc.append(sheet.cell(i,4).value)
#读取B30工作表,存入list
cnt=0
for songtitle in Songs:
found=False
for i in songdic['songs']:
if(i['title_localized']['en']==songtitle):#暴力在songdic里搜索我要的歌。如果找不到那就崩tm的溃(划掉)
song_id.append(i['id'])
song_dl.append(i.get('remote_dl',False))
song_index.append(cnt)
found=True
break
if( not found):
print('ERROR:cannot find ',songtitle)
break
cnt=cnt+1
avg=avg/(cnt)#B30平均PTT计算
output=Image.open(os.path.join(gen_path,'src/bgs',random.choice(bg)))
#随机选取src/bgs/中的一张图片作背景。大小2560*1440.
print('Background image fetched. Processing...')
for x in range(1,2560):
for y in range(1,1440):
rgb=output.getpixel((x,y))
output.putpixel((x,y),(int(rgb[0]/1.5),int(rgb[1]/1.5),int(rgb[2]/1.5)))
#整体暗化,不然字看不清。但是效率巨低
print('Process complete. Stitching song & data...')
draw = ImageDraw.Draw(output)#之后所有绘制操作都经由这一个draw
for i in song_index:
if(song_dl[i]):
dlstr='dl_'
else:
dlstr=''
#处理需要下载的歌曲的特殊文件夹名。不解包还不知道。616你做得好,做得好啊
if(diff[i].lower()=='byd'):
flstr='3.jpg'
else:
flstr='base.jpg'
#处理byd难度的特殊曲绘
im=Image.open(os.path.join(gen_path,'src/songs',dlstr+song_id[i],flstr))#打开对应曲绘
im=im.resize((240,240))#调整大小,一个图总共占256*356空间,多出来的留作空白和放数据文字
output.paste(im,(256*(i%10)+8,350+356*(i//10)+8,256*(i%10+1)-8,350+356*(i//10+1)-108))#基础坐标计算。image.paste(src,(x1,y1,x2,y2))
for x in range(256*(i%10+1)-16,256*(i%10+1)-8):
for y in range(350+356*(i//10)+8,350+356*(i//10+1)-108):
output.putpixel((x,y),clr[diff[i]])#在曲绘右边绘制一个8px的竖直长条矩形,颜色用对应难度颜色填充
output.paste(badge[diff[i]],(256*(i%10+1)-8-60,350+356*(i//10),256*(i%10+1)-8-60+73,350+356*(i//10)+22))#贴上对应难度的tag。同样是基础坐标计算。
if(Sc[i]>=10000000):
output.paste(pm,(256*(i%10+1)-8-97,350+356*(i//10+1)-108-73,256*(i%10+1)-8+10,350+356*(i//10+1)-108+10),pm)#如果你pm了,奖励你一朵小P花
draw.text((256*(i%10)+8,350+356*(i//10+1)-105),'%s%.6f'%('ptt:',ptt[i]),(255,230,255),font)#绘制单曲ptt
draw.text((256*(i%10)+8,350+356*(i//10+1)-55),'%d%s'%(Sc[i],'pts'),(255,230,255),font)#绘制单曲得分
draw.text((80,40),'%s%s'%('Player:',player),(255,230,255),titlefont)#绘制标题:玩家名
draw.text((80,180),'%s%.6f'%('B30 Average:',avg),(220,200,220),titlefont)#绘制标题:均PTT
output.save(os.path.join(savepath,"output.png"),"PNG")#导出到目标路径
print("Complete!")
#End Main
root.destroy()#销毁开局tk留下的窗口。tk这波纯对话框工具人