再战ArcaeaB30生成器:Python模块PIL实战图像处理与拼接

书接上回ArcaeaB30录入和导出:Python简单的xlsx、json处理和图片编辑
这次是参考上一次经验,经过完全重写的版本的开发笔记。


光光看着憨驴的你
结果如下:
这是图片描述


前言

  • 何为B30?
    这是一个围绕移动端节奏游戏Arcaea的玩家数据潜力值展开的话题,具体机制可参阅Arcaea中文维基
    B30即Best 30,玩家若能知晓自己的B30具体内容,可以更有针对性地练歌推歌性歌弃坑,更高效地提升潜力值,因此较为重要。
  • 此程序解决的问题是:在得到玩家的数据后,转换成信息高度集中且易读的一图流B30报表

下面是按照我的开发思路回忆整理的笔记正文。

一、数据从哪来?从社区中现有的B30查询手段说起

在我的上一次开发中,我使用了手动在电脑端录入Excel表格这一手段获取到玩家打歌的信息。此方法弊端很突出,具体表现为:

  • 双端操作、手动打字输入带来的麻烦
  • 不低的出错率

而在上一次开发之后,我尝试了一种由其他玩家提出过的方案:把打歌成绩截图用光学文字识别转换成可用的信息并记录,并在成功之后尝试将程序转移到移动端。但受我经验积累不足影响,识别准确率依然不高,在第一步便基本宣告失败。

事实上,社区主流查询手段大多是基于QQ机器人(例如软糖酱)的查询,其独立完成模拟玩家查询的数据请求、处理、图片生成等工作并直接返回成图。但在最近的一次(或几次)更新中,Bot查分受到了很大影响,存活无几。

囿于本人技术限制,不能做到独立完成数据请求的工作,因此我转而着手“借用”在线查分器返回的结果进行二次处理。于是有了这一次开发。
我并没有直接或间接联系在线查分服务的提供者或维护者,只提供一种数据二次加工的手段;以前且查且珍惜,现在仍然不会改变,不要滥用他人提供的服务。

二、Excel侧数据读入与宏处理

在网站上完成查询之后,自己的打歌数据可以用其自带的“导出CSV”功能保存表格到本地,但是该方法不保存详细数据(玩家名/潜力值详细数值/打歌详细成绩和日期等),因此我选择手动复制HTML元素到Excel处理。
不用爬虫,还是那句话,且查且珍惜

以及,输入按照如下操作进行:
Step1
第一步,输入玩家UID开始查询,往下拉找到玩家信息这三行,复制并直接粘贴到工作簿的第一行至第三行;
Step2
第二步,等待查询程序完成所有成绩拉取工作后,按下F12,找到id为scores的div元素,右键-复制-复制元素,然后到工作表的第四行直接粘贴。

获得数据之后,直接写宏,全部都是 高中级别的 字符串处理。过程略。

结果是保存到同一工作簿下的Song和Player两个工作簿,分别保存玩家数据和打歌成绩数据。
宏处理结果

三、Python侧程序

用到的模块:
os sys:本地路径相关的操作
tkinter.filedialog:文件打开对话框
json:处理json文件
openpyxl:处理Excel工作簿
PILImage ImageFont ImageDraw:处理图片

1.素材加载

后面生成图片的时候素材都是要自己贴上去的,因此我选择提前加载上所有资源,这样如果资源缺失就直接提醒(报错退出)了

获取程序所处位置:
root_path = os.path.dirname(os.path.realpath(sys.argv[0]))

检测文件夹存在:

def checkSrc():
    if not os.path.isdir(os.path.join(root_path, 'src')):
        return True

然后在try下面加载,这样如果缺了可以捕获到错误提示缺了啥。
字体, json文件 和图片文件都要加载。
篇幅过于冗长,代码留给最后再放。

2.打开文件

写一个requirePath函数,顺便在函数内完成检查工作:

def requirePath():
    global filename
    global savepath
    print('Select a xlsx file:')
    filename = tkinter.filedialog.askopenfilename(title = '选择文件', filetypes=[('Excel工作簿','.xlsx'), ('Excel启用宏的工作簿','.xlsm'), ('Excel2003工作簿','.xls'), ('所有文件','.*')])
    if not os.path.isfile(filename):
        print('无效的文件名: %s'%filename)
        return -1
    print('filename = %s'%filename)
    print('Determine save path:')
    savepath = tkinter.filedialog.askdirectory(title = '选择保存路径')
    if not os.path.isdir(savepath):
        print('无效的保存地址: %s'%savepath)
        return -1
    print('savepath = %s'%savepath)
    return 0

这一段没什么好说道的

3.加载工作簿,获取信息

openpyxl提供了加载工作簿的方法。
book = openpyxl.load_workbook(path),返回工作簿对象存给book
sheet = book['Sheet1']其中的Sheet1表存给sheet,表名按需改
sheet.cell(row,column).value返回row行,column列的单元格的值。
需要注意的是:直接用sheet.cell(r,c)返回的是cell对象,而不是其中的值,不好直接拿来计算

写一个函数:

def openWorkbook(path):
    global sheet
    sheet={}
    try:
        book = openpyxl.load_workbook(path)
    except Exception as e:
        print('加载工作簿出错,详细信息:')
        print(e)
        exit(1)
    try:
        sheet['song'] = book['Song']
    except Exception as e:
        print('找不到数据,确保主表名为Song')
        exit(1)
    try:
        sheet['player'] = book['Player']
    except Exception as e:
        pass

找不到玩家信息表选择pass,是因为我希望程序能兼容.csv导出的那种形式的文件,即不含详细信息也能出来。
然后在主程序里用一个while循环读取数据,i是工作表中的列号(1是表头,从2开始):

while not sheet['song'].cell(i,1).value == None and i<=34:
    print('歌曲#%d生成...'%(i-2))
    temp=[]
    for j in range(1,11):
        if j>=5 and j<=9 and sheet['song'].cell(i,j).value==None:
            temp.append(0)
        else:
            temp.append(sheet['song'].cell(i,j).value)
    temp.append(i-1)
    cards.append(makeCard(temp))#makeCard是接下来要定义的一个函数
    i=i+1

循环条件中,读到空意味着数据读完了,这很正常;i<34是最大读取33首歌的信息,因为最多咱要放到图里的只有33首:best30内的,外加有希望冲进b30的额外三首歌。

4.逐个制作信息卡

这一次开发与上一次的思路明显不同的地方就在于此。
之前我选择一遍遍历加底图,再一遍遍历加信息,再一遍加这,再一遍加那,代码就显得很冗长。
这一次用一点模块化的思路,我把一个成绩给你,你给我一个卡,集卡集满了我再一起缝到背景上。

这里就要写上一节用到的makeCard函数。
分析输入:我用while把工作表中一行,也就是一首歌的成绩全部打包成一个List了,接下来我将要把这个List传给函数。
分析输出:我希望它直接返回给我一个PIL的图片对象。

全代码如下:

def makeCard(data):
    songjpg = searchSongData(data[0],data[1],data[9])#见下文,另一个自定义函数,用来查歌
    if data[10]<31:
        r=0
        g=0
        b=0
        for i in range(0,20):
            for j in range(0,130):
                rgb=songjpg.getpixel((i,j))
                r=r+rgb[0]
                g=g+rgb[1]
                b=b+rgb[2]
        r=int(r/3200)
        g=int(g/3200)
        b=int(b/3200)
        card = Image.new('RGB',(390,130),'#'+str(hex(r))[-2:].replace('x','0')+str(hex(g))[-2:].replace('x','0')+str(hex(b))[-2:].replace('x','0'))
    else:
        card = Image.new('RGB',(390,130),'#b68f17')
    
    card.paste(songjpg,(259,0),mask[0])
    card.paste(diff[data[1].upper()],(11,5),diff['BYD'])
    card.paste(arrowpng,(60,77),arrowpng)
    try:#clear status
        card.paste(badge[data[8]],(270,70),badge[data[8]])
    except Exception as e:
        pass
    if data[4]==0:#pure,far,lost
        card.paste(Image.new('RGB',(9,74),'#333333'),(170,43),mask[2])
    else:
        accbar=Image.new('RGB',(9,74),'#a55cb4')#max
        accbar.paste(Image.new('RGB',(9,74),'#794484'),(0,int(74*int(data[5])/2/(int(data[4])/2+int(data[6])+int(data[7])))))#pure
        accbar.paste(Image.new('RGB',(9,74),'#FFAA11'),(0,int(74*int(data[4])/2/(int(data[4])/2+int(data[6])+int(data[7])))))#far
        accbar.paste(Image.new('RGB',(9,74),'#DD4444'),(0,int(74*(int(data[4])/2+int(data[6]))/(int(data[4])/2+int(data[6])+int(data[7])))))#lost
        card.paste(accbar,(170,43),mask[2])
    card.paste(grade[pts2grade(int(data[2]))],(207,76),grade[pts2grade(int(data[2]))])
    #text
    draw = ImageDraw.Draw(card)
    drawText(draw,26,1,'#'+str(data[10])+' '+data[0][:15],(237,237,237),0,1,True,(35,35,35))
    drawText(draw,27,43,format(int(data[2]),',')+'pts',(237,237,237),1,1,False,(77,77,77))
    if data[4]!=0:
        drawText(draw,187,42,"%d(+%d)"%(data[4],data[5]),(237,237,237),2)
        drawText(draw,187,71,data[6],(237,237,237),2)
        drawText(draw,187,100,data[7],(237,237,237),2)
    if data[9]==0:
        drawText(draw,24,89,'??',(188,188,188),4,1,False,(77,77,77))
    else:
        drawText(draw,24,89,data[9],(188,188,188),4,1,False,(77,77,77))
    drawText(draw,95,85,'%.2f'%(data[3]),(237,237,237),3,1,False,(77,77,77))
    return card

很长,我们一点点看。

a.获得曲绘

songjpg = searchSongData(data[0],data[1],data[9])
用一个函数,用给出的信息搜索对应歌曲的曲绘。该函数如下:

def searchSongData(title,diff):
    for i in songdic['songs']:
        if i['title_localized']['en']==title:
            dl_str=''
            by_str='base.jpg'
            if i.get('remote_dl',False):
                dl_str='dl_'
            if diff.upper()=='BYD':
                by_str='3.jpg'
                return Image.open(os.path.join(root_path,'src','songs',dl_str+i['id'],by_str)).resize((130,130))
    print('没找到歌曲:%s,检查src对应版本号或数据文件'%title)
    return Image.new('RGB',(130,130),'#333333')

songdic是上文加载资源的时候,json文件转成的字典。
if i.get('remote_dl',False):
需要下载的歌曲有remote_dl=True,而不需要下载的歌曲没有对应的键值。所以这里是带默认值的获取,没有这一个键就缺省为False。

然后细心的同学发现了:诶上面明明传进来3个变量啊,怎么这里只有2个呢?
因为后来被双星人大佬指出:Arcaea中有两首Quon,一首ftr8 & byd10,另一首ftr9+,名字一模一样,所以只凭名字是无法区分这两首歌的。作为补救措施,加传了一个值,也就是打的这首歌的定数,用它来判断是哪首。因此在最终版本的代码里传了三个参。
但是加了额外的判断之后代码可读性下降了,完整的代码留到最后展示,各位知道有这么回事就行

b.卡底生成

正如整张图片是在背景上涂涂画画,这里做的小卡片同样需要一个基底。
怎么来?
我将用Image.new方法创建一个带颜色的 390*130 矩形,赋给card变量

    if data[10]<31:
        r=0
        g=0
        b=0
        for i in range(0,20):
            for j in range(0,130):
                rgb=songjpg.getpixel((i,j))
                r=r+rgb[0]
                g=g+rgb[1]
                b=b+rgb[2]
        r=int(r/3200)
        g=int(g/3200)
        b=int(b/3200)
        card = Image.new('RGB',(390,130),'#'+str(hex(r))[-2:].replace('x','0')+str(hex(g))[-2:].replace('x','0')+str(hex(b))[-2:].replace('x','0'))
    else:
        card = Image.new('RGB',(390,130),'#b68f17')

这里对于Best30中的歌曲计算了曲绘中左侧20x130像素点的平均RGB值,稍微减暗一点作为背景色。
之外的那三首使用统一的土黄色#b68f17作为背景。

c.信息添加

接下来,先把各个图片元素粘贴上去。所有元素(图片、文字等)都对应某项信息,细的不必深究,大概看个回事就行。

    card.paste(songjpg,(259,0),mask[0])
    card.paste(diff[data[1].upper()],(11,5),diff['BYD'])
    card.paste(arrowpng,(60,77),arrowpng)
    try:#clear status
        card.paste(badge[data[8]],(270,70),badge[data[8]])
    except Exception as e:
        pass
    if data[4]==0:#pure,far,lost
        card.paste(Image.new('RGB',(9,74),'#333333'),(170,43),mask[2])
    else:
        accbar=Image.new('RGB',(9,74),'#a55cb4')#max
        accbar.paste(Image.new('RGB',(9,74),'#794484'),(0,int(74*int(data[5])/2/(int(data[4])/2+int(data[6])+int(data[7])))))#pure
        accbar.paste(Image.new('RGB',(9,74),'#FFAA11'),(0,int(74*int(data[4])/2/(int(data[4])/2+int(data[6])+int(data[7])))))#far
        accbar.paste(Image.new('RGB',(9,74),'#DD4444'),(0,int(74*(int(data[4])/2+int(data[6]))/(int(data[4])/2+int(data[6])+int(data[7])))))#lost
        card.paste(accbar,(170,43),mask[2])
    card.paste(grade[pts2grade(int(data[2]))],(207,76),grade[pts2grade(int(data[2]))])

这里在paste的时候使用到了遮罩(mask),大概是贴出比较美观的圆角矩形的最简单的方法了。后面还会用到。
遮罩用到的PNG文件提前用Photoshop做好,在加载资源的时候加载到名为masks的List中了。
pts2grade是一个将分数转换成对应评级的自定义函数

不一定有的元素要么打上try,要么用if判断一下。
以及:这里我整了点花活,即根据各个判定1的占比画一个竖直长条来反映整体精准度,即代码中的accbar

然后是文字部分。在这之前,我自己写了一个drawText函数用来生成带描边的文字:

def drawText(drawObj,x,y,content,color,font_num,outline=0,outlinebold=False,outlinecolor=(0,0,0)):
    if outline>0:
        if outlinebold:
            for i in range(1,outline+1):
                for j in range(1,outline+1):
                    drawObj.text((x-i,y-j),str(content),outlinecolor,font[font_num])
                    drawObj.text((x-i,y+j),str(content),outlinecolor,font[font_num])
                    drawObj.text((x+i,y-j),str(content),outlinecolor,font[font_num])
                    drawObj.text((x+i,y+j),str(content),outlinecolor,font[font_num])
        else:
            for i in range(1,outline+1):
                drawObj.text((x-i,y),str(content),outlinecolor,font[font_num])
                drawObj.text((x+i,y),str(content),outlinecolor,font[font_num])
                drawObj.text((x,y-i),str(content),outlinecolor,font[font_num])
                drawObj.text((x,y+i),str(content),outlinecolor,font[font_num])
    drawObj.text((x,y),str(content),color,font[font_num])

怎么画描边应该一眼就看懂了罢。然后把它用到makeCard函数中:

    draw = ImageDraw.Draw(card)
    drawText(draw,26,1,'#'+str(data[10])+' '+data[0][:15],(237,237,237),0,1,True,(35,35,35))
    drawText(draw,27,43,format(int(data[2]),',')+'pts',(237,237,237),1,1,False,(77,77,77))
    if data[4]!=0:
        drawText(draw,187,42,"%d(+%d)"%(data[4],data[5]),(237,237,237),2)
        drawText(draw,187,71,data[6],(237,237,237),2)
        drawText(draw,187,100,data[7],(237,237,237),2)
    if data[9]==0:
        drawText(draw,24,89,'??',(188,188,188),4,1,False,(77,77,77))
    else:
        drawText(draw,24,89,data[9],(188,188,188),4,1,False,(77,77,77))
    drawText(draw,95,85,'%.2f'%(data[3]),(237,237,237),3,1,False,(77,77,77))

format(int(data[2]),',')的效果是将数字用逗号分隔。例如10000000变成10,000,000,与游戏中的显示形式相同。
以及标题处非常粗暴地限制15个字符就完事了,如果要认真做的话可以加个遮罩的图片优化效果。
为什么加描边呢?其实很好理解的嘛要是不加的话有些歌是白底那么白字当然就看不清了。但是Arcaea用的字体GeosansLight实在太细了,加了描边也不是很美观
(字本身可能就一两个像素粗,还要加描边那几乎是描边粗了)
凑合着用吧

d.完成

好那么到这里为止卡面已经完全生成了,测试的时候可以打一句card.show()看一眼
最后的最后,返回

	return card

5.全部缝合到背景

a.卡片的缝合

在主程序内打开背景,用一个循环把cards里的卡片一张张贴上去就行了。
i是上文遍历工作簿时使用的变量,i-2即所有成绩的个数。

print('全部歌曲卡片生成完成,开始拼接')
bg = Image.open(os.path.join(root_path,'src','bg.jpg'))
for j in range(0,min(30,i-2)):
    bg.paste(cards[j],(45+395*(j%3),525+135*(j//3)),mask[1])
if i>32:
    for j in range(30,min(i-2,33)):
        bg.paste(cards[j],(45+395*(j%3),545+135*(j//3)),mask[1])

坐标都提前量好,然后测试的时候来回看几眼调整,八九不离十。
这里的做法是 :

  • 如果成绩数不到30项,那么全贴就行了
  • 如果成绩数大于30,那么竖直方向隔开20px,再把第31~33项成绩(若有)贴上去

b.文字信息(玩家信息)的缝合

需要的玩家信息之前没有拿,现场获取一下。
主程序内如下代码:

try:
    playerData=getPlayerData(sheet['player'])
except Exception as e:
    playerData=['Unknown Player',0.00,b30/30,0.00,b30max/10]
stitchBg(bg, playerData)

依然希望能兼容常规方法导出的文件,因此做了一下默认参数。
getPlayerData返回List形式的玩家数据,代码很简单就留到最后了。

再传递给stitchBg函数:

def stitchBg(bg,playerdata):
    draw = ImageDraw.Draw(bg)
    bg.paste(ptt2image(playerdata[1]),(58,50),ptt2image(playerdata[1]))
    if playerdata[1]==0:
        width = get_font_render_size(font[5],'--.--')[0]
        drawText(draw,158-width//2,102,'--.--',(237,237,237),5,5,True,(63,63,63))
    else:
        width = get_font_render_size(font[5],str(playerdata[1]))[0]
        drawText(draw,148-width//2,102,str(playerdata[1]),(237,237,237),5,5,True,(63,63,63))
    drawText(draw,277,88,playerdata[0],(237,237,237),7,5,True,(63,63,63))
    drawText(draw,642,224,'Best30:',(237,237,237),8)
    drawText(draw,642,335,'Recent10:',(237,237,237),8)
    drawText(draw,642,442,'MaxPossible:',(237,237,237),8)
    drawText(draw,1010,224,'%.5f'%playerdata[2],(237,237,237),8)
    drawText(draw,1010,335,'%.5f'%playerdata[3],(237,237,237),8)
    drawText(draw,1010,442,'%.5f'%playerdata[4],(237,237,237),8)

这里的if依然是做默认值。
ptt2image返回当前玩家潜力值对应的图片背景。
原理是一样的,只是累一点,没啥技术含量

6.保存

已经来到最后一步了。写一个保存的函数savePic

def savePic(pic, path):
    try:
        pic.save(os.path.join(path,'output.png'),'PNG')
    except Exception as e:
        print('保存到此路径失败:%s'%path)
        exit(1)
    print('成功保存:%s'%os.path.join(path,'output.png'))

然后在主程序里调用就可以了。
也可以顺手写一个bg.show()放出预览。但是这一方法是不能用于保存图片的,show的一瞬间生成的临时图片会马上被删除。

效果展示

我的个人B30图:
可以看到,最上面是个人潜力值和玩家名显示,以及潜力值的 详细数值2 显示。接着是所有成绩的排列,一张卡片从上到下,从左到右依次显示的信息是:

  • 排名和歌曲名
  • 分数
  • 定数 -> 成绩定数
  • Pure,Far,Lost的判定数显示
  • 成绩评级
  • 通关类型

这是图片描述
以及我某位双星人好友的导出图~~(展示未经同意)~~(逃
拜他所赐发现了Quon的漏洞
无图片描述
以及不含详细信息,只有B30成绩的导出效果:
UP

总结

还好上次留了笔记啊不然现在就算想复制粘贴一下子都看不懂了www
总之非常感谢所有人
以及再次默默感谢在线查询服务的提供者,提醒各位且查且珍惜。
光光还是看着憨驴的你

附录:Python源码

import json
import tkinter.filedialog
import os
import sys
from PIL import Image, ImageFont, ImageDraw
import openpyxl
root_path = os.path.dirname(os.path.realpath(sys.argv[0]))#这是程序所处的位置
font=[]
badge={}
diff={}
grade={}
mask=[]
rating=[]


def checkSrc():
    if not os.path.isdir(os.path.join(root_path, 'src')):
        return True
def loadRes(srcpath):
    print('开始加载资源,请稍候')
    global songdic
    try:
        #songlist打开
        filejson = open(os.path.join(srcpath,'songs','songlist'), 'r', encoding='UTF-8')
        songdic=json.load(filejson)
        filejson.close()
        #src/fonts
        font.append(ImageFont.truetype(os.path.join(srcpath,'fonts','score.ttf'), 30))#标题
        font.append(ImageFont.truetype(os.path.join(srcpath,'fonts','score.ttf'), 24))#pts
        font.append(ImageFont.truetype(os.path.join(srcpath,'fonts','score.ttf'), 18))#pure,far,lost
        font.append(ImageFont.truetype(os.path.join(srcpath,'fonts','ptt.ttf'), 30))#结果ptt
        font.append(ImageFont.truetype(os.path.join(srcpath,'fonts','ptt.ttf'), 24))#定数
        font.append(ImageFont.truetype(os.path.join(srcpath,'fonts','ptt.ttf'), 72))
        font.append(ImageFont.truetype(os.path.join(srcpath,'fonts','ptt.ttf'), 60))
        font.append(ImageFont.truetype(os.path.join(srcpath,'fonts','ptt.ttf'), 100))
        font.append(ImageFont.truetype(os.path.join(srcpath,'fonts','ptt.ttf'), 50))
        #src/png/card
        badge['Easy Clear']=Image.open(os.path.join(srcpath,'png/card/badge/ez.png')).resize((60,47))
        badge['Normal Clear']=Image.open(os.path.join(srcpath,'png/card/badge/nm.png')).resize((60,47))
        badge['Hard Clear']=Image.open(os.path.join(srcpath,'png/card/badge/hd.png')).resize((60,47))
        badge['Full Recall']=Image.open(os.path.join(srcpath,'png/card/badge/fr.png')).resize((60,47))
        badge['Pure Memory']=Image.open(os.path.join(srcpath,'png/card/badge/pm.png')).resize((60,47))
        badge['Track Lost']=Image.open(os.path.join(srcpath,'png/card/badge/fail.png')).resize((60,47))
        diff['BYD']=Image.open(os.path.join(srcpath,'png/card/diff/byd.png'))
        diff['FTR']=Image.open(os.path.join(srcpath,'png/card/diff/ftr.png'))
        diff['PRS']=Image.open(os.path.join(srcpath,'png/card/diff/prs.png'))
        diff['PST']=Image.open(os.path.join(srcpath,'png/card/diff/pst.png'))
        grade['EX+']=Image.open(os.path.join(srcpath,'png/card/grade/ex+.png')).resize((72,35))
        grade['EX']=Image.open(os.path.join(srcpath,'png/card/grade/ex.png')).resize((72,35))
        grade['AA']=Image.open(os.path.join(srcpath,'png/card/grade/aa.png')).resize((72,35))
        grade['A']=Image.open(os.path.join(srcpath,'png/card/grade/a.png')).resize((72,35))
        grade['B']=Image.open(os.path.join(srcpath,'png/card/grade/b.png')).resize((72,35))
        grade['C']=Image.open(os.path.join(srcpath,'png/card/grade/c.png')).resize((72,35))
        grade['D']=Image.open(os.path.join(srcpath,'png/card/grade/d.png')).resize((72,35))
        rating.append(Image.open(os.path.join(srcpath,'png/ptt/rating_0.png')).resize((200,200)))
        rating.append(Image.open(os.path.join(srcpath,'png/ptt/rating_1.png')).resize((200,200)))
        rating.append(Image.open(os.path.join(srcpath,'png/ptt/rating_2.png')).resize((200,200)))
        rating.append(Image.open(os.path.join(srcpath,'png/ptt/rating_3.png')).resize((200,200)))
        rating.append(Image.open(os.path.join(srcpath,'png/ptt/rating_4.png')).resize((200,200)))
        rating.append(Image.open(os.path.join(srcpath,'png/ptt/rating_5.png')).resize((200,200)))
        rating.append(Image.open(os.path.join(srcpath,'png/ptt/rating_6.png')).resize((200,200)))
        rating.append(Image.open(os.path.join(srcpath,'png/ptt/rating_off.png')).resize((200,200)))
        
        global arrowpng
        arrowpng=Image.open(os.path.join(srcpath,'png/card/arrow.png')).resize((41,55))
        #src/png/mask
        mask.append(Image.open(os.path.join(srcpath,'png/mask/songmask_130px.png')))
        mask.append(Image.open(os.path.join(srcpath,'png/mask/cardmask_390x130.png')))
        mask.append(Image.open(os.path.join(srcpath,'png/mask/accbarmask_9x74.png')))
        #src/png/ptt
        for i in ['0','1','2','3','4','5','6','off']:
            rating.append(Image.open(os.path.join(srcpath,'png/ptt/rating_'+i+'.png')))
        #Done.
        print('100%')
    except Exception as e:
        print('加载资源出错,详细信息:')
        print(e)
        exit(1)
def requirePath():
    global filename
    global savepath
    print('Select a xlsx file:')
    filename = tkinter.filedialog.askopenfilename(title = '选择文件', filetypes=[('Excel工作簿','.xlsx'), ('Excel启用宏的工作簿','.xlsm'), ('Excel2003工作簿','.xls'), ('所有文件','.*')])
    if not os.path.isfile(filename):
        print('无效的文件名: %s'%filename)
        return -1
    print('filename = %s'%filename)
    print('Determine save path:')
    savepath = tkinter.filedialog.askdirectory(title = '选择保存路径')
    if not os.path.isdir(savepath):
        print('无效的保存地址: %s'%savepath)
        return -1
    print('savepath = %s'%savepath)
    return 0
def openWorkbook(path):
    global sheet
    sheet={}
    try:
        book = openpyxl.load_workbook(path)
    except Exception as e:
        print('加载工作簿出错,详细信息:')
        print(e)
        exit(1)
    try:
        sheet['song'] = book['Song']
    except Exception as e:
        print('找不到数据,确保主表名为Song')
        exit(1)
    try:
        sheet['player'] = book['Player']
    except Exception as e:
        pass
    
    
def searchSongData(title,diff,rate=0):
    quonflag=True
    for i in songdic['songs']:
        if i['title_localized']['en']==title:
            if title=='Quon' and quonflag:
                if rate<9 or rate>=10:
                    quonflag=False
                    continue
            dl_str=''
            by_str='base.jpg'
            if i.get('remote_dl',False):
                dl_str='dl_'
            if diff.upper()=='BYD':
                by_str='3.jpg'
            try:
                return Image.open(os.path.join(root_path,'src','songs',dl_str+i['id'],by_str)).resize((130,130))
            except Exception as e:
                if diff.upper()=='BYD':
                    by_str='base.jpg'
                    return Image.open(os.path.join(root_path,'src','songs',dl_str+i['id'],by_str)).resize((130,130))
    print('没找到歌曲:%s,检查src对应版本号或数据文件'%title)
    return Image.new('RGB',(130,130),'#333333')
def pts2grade(score):
    if score>=9900000:
        return 'EX+'
    elif score>=9800000:
        return 'EX'
    elif score>=9500000:
        return 'AA'
    elif score>=9200000:
        return 'A'
    elif score>=8900000:
        return 'B'
    elif score>=8600000:
        return 'C'
    else:
        return 'D'
def drawText(drawObj,x,y,content,color,font_num,outline=0,outlinebold=False,outlinecolor=(0,0,0)):
    if outline>0:
        if outlinebold:
            for i in range(1,outline+1):
                for j in range(1,outline+1):
                    drawObj.text((x-i,y-j),str(content),outlinecolor,font[font_num])
                    drawObj.text((x-i,y+j),str(content),outlinecolor,font[font_num])
                    drawObj.text((x+i,y-j),str(content),outlinecolor,font[font_num])
                    drawObj.text((x+i,y+j),str(content),outlinecolor,font[font_num])
        else:
            for i in range(1,outline+1):
                drawObj.text((x-i,y),str(content),outlinecolor,font[font_num])
                drawObj.text((x+i,y),str(content),outlinecolor,font[font_num])
                drawObj.text((x,y-i),str(content),outlinecolor,font[font_num])
                drawObj.text((x,y+i),str(content),outlinecolor,font[font_num])
    drawObj.text((x,y),str(content),color,font[font_num])
def makeCard(data):
    songjpg = searchSongData(data[0],data[1],data[9])
    if data[10]<31:
        r=0
        g=0
        b=0
        for i in range(0,20):
            for j in range(0,130):
                rgb=songjpg.getpixel((i,j))
                r=r+rgb[0]
                g=g+rgb[1]
                b=b+rgb[2]
        r=int(r/3200)
        g=int(g/3200)
        b=int(b/3200)
        card = Image.new('RGB',(390,130),'#'+str(hex(r))[-2:].replace('x','0')+str(hex(g))[-2:].replace('x','0')+str(hex(b))[-2:].replace('x','0'))
    else:
        card = Image.new('RGB',(390,130),'#b68f17')
    
    card.paste(songjpg,(259,0),mask[0])
    card.paste(diff[data[1].upper()],(11,5),diff['BYD'])
    card.paste(arrowpng,(60,77),arrowpng)
    try:#clear status
        card.paste(badge[data[8]],(270,70),badge[data[8]])
    except Exception as e:
        pass
    if data[4]==0:#pure,far,lost
        card.paste(Image.new('RGB',(9,74),'#333333'),(170,43),mask[2])
    else:
        accbar=Image.new('RGB',(9,74),'#a55cb4')#max
        accbar.paste(Image.new('RGB',(9,74),'#794484'),(0,int(74*int(data[5])/2/(int(data[4])/2+int(data[6])+int(data[7])))))#pure
        accbar.paste(Image.new('RGB',(9,74),'#FFAA11'),(0,int(74*int(data[4])/2/(int(data[4])/2+int(data[6])+int(data[7])))))#far
        accbar.paste(Image.new('RGB',(9,74),'#DD4444'),(0,int(74*(int(data[4])/2+int(data[6]))/(int(data[4])/2+int(data[6])+int(data[7])))))#lost
        card.paste(accbar,(170,43),mask[2])
    card.paste(grade[pts2grade(int(data[2]))],(207,76),grade[pts2grade(int(data[2]))])
    #text
    draw = ImageDraw.Draw(card)
    drawText(draw,26,1,'#'+str(data[10])+' '+data[0][:15],(237,237,237),0,1,True,(35,35,35))
    drawText(draw,27,43,format(int(data[2]),',')+'pts',(237,237,237),1,1,False,(77,77,77))
    if data[4]!=0:
        drawText(draw,187,42,"%d(+%d)"%(data[4],data[5]),(237,237,237),2)
        drawText(draw,187,71,data[6],(237,237,237),2)
        drawText(draw,187,100,data[7],(237,237,237),2)
    if data[9]==0:
        drawText(draw,24,89,'??',(188,188,188),4,1,False,(77,77,77))
    else:
        drawText(draw,24,89,data[9],(188,188,188),4,1,False,(77,77,77))
    drawText(draw,95,85,'%.2f'%(data[3]),(237,237,237),3,1,False,(77,77,77))
    return card
def getPlayerData(sheet):
    data=[]
    for i in range(0,5):
        data.append(sheet.cell(i+1,2).value)
    return data
def get_font_render_size(font,content):
    canvas=Image.new('RGB',(1024,1024))
    draw=ImageDraw.Draw(canvas)
    draw.text((0,0),content,(255,255,255),font)
    bbox=canvas.getbbox()
    size=(bbox[2]-bbox[0],bbox[3]-bbox[1])
    return size
def ptt2image(ptt):
    if ptt>=12.5:
        return rating[6]
    elif ptt>=12:
        return rating[5]
    elif ptt>=11:
        return rating[4]
    elif ptt>=10:
        return rating[3]
    elif ptt>=7:
        return rating[2]
    elif ptt>=3.5:
        return rating[1]
    elif ptt>0:
        return rating[0]
    else:
        return rating[7]
def stitchBg(bg,playerdata):
    draw = ImageDraw.Draw(bg)
    bg.paste(ptt2image(playerdata[1]),(58,50),ptt2image(playerdata[1]))
    if playerdata[1]==0:
        width = get_font_render_size(font[5],'--.--')[0]
        drawText(draw,158-width//2,102,'--.--',(237,237,237),5,5,True,(63,63,63))
    else:
        width = get_font_render_size(font[5],str(playerdata[1]))[0]
        drawText(draw,148-width//2,102,str(playerdata[1]),(237,237,237),5,5,True,(63,63,63))
    drawText(draw,277,88,playerdata[0],(237,237,237),7,5,True,(63,63,63))
    drawText(draw,642,224,'Best30:',(237,237,237),8)
    drawText(draw,642,335,'Recent10:',(237,237,237),8)
    drawText(draw,642,442,'MaxPossible:',(237,237,237),8)
    drawText(draw,1010,224,'%.5f'%playerdata[2],(237,237,237),8)
    drawText(draw,1010,335,'%.5f'%playerdata[3],(237,237,237),8)
    drawText(draw,1010,442,'%.5f'%playerdata[4],(237,237,237),8)
def savePic(pic, path):
    try:
        pic.save(os.path.join(path,'output.png'),'PNG')
    except Exception as e:
        print('保存到此路径失败:%s'%path)
        exit(1)
    print('成功保存:%s'%os.path.join(path,'output.png'))
    
#def Main():
root = tkinter.Tk()
root.withdraw()
#打开文件
if checkSrc():
    print('目录下没有src文件夹,请查看:%s'%os.path.join(root_path, 'src'))
    exit(1)
loadRes(os.path.join(root_path, 'src'))
if requirePath()<0:
    exit(1)
openWorkbook(filename)
i=2
cards=[]
b30=0.0
b30max=0.0
while not sheet['song'].cell(i,1).value == None and i<=34:
    print('歌曲#%d生成...'%(i-2))
    temp=[]
    for j in range(1,11):
        if j>=5 and j<=9 and sheet['song'].cell(i,j).value==None:
            temp.append(0)
        else:
            temp.append(sheet['song'].cell(i,j).value)
    temp.append(i-1)
    cards.append(makeCard(temp))
    if i<=31:
        b30=b30+temp[3]
    if i<=11:
        b30max=b30max+temp[3]
    i=i+1
print('全部歌曲卡片生成完成,开始拼接')
bg = Image.open(os.path.join(root_path,'src','bg.jpg'))
for j in range(0,min(30,i-2)):
    bg.paste(cards[j],(45+395*(j%3),525+135*(j//3)),mask[1])
if i>32:
    for j in range(30,min(i-2,33)):
        bg.paste(cards[j],(45+395*(j%3),545+135*(j//3)),mask[1])
try:
    playerData=getPlayerData(sheet['player'])
except Exception as e:
    playerData=['Unknown Player',0.00,b30/30,0.00,b30max/10]
stitchBg(bg, playerData)
bg.show()
savePic(bg, savepath)

root.destroy()




  1. 音游术语,玩家打击精准度不同会得到不同的判定。在Arcaea中有四个判定等级,即Pure, Far, Lost;Pure下面还有MaxPure(俗称大P)和Pure(俗称小P) ↩︎

  2. 依次:Best30即B30所有成绩的平均值,Recent10即最近游玩的10次成绩的平均值,此两者计算得出潜力值;MaxPossible是不更新最高分的情况下玩家潜力值的最大值,即R10取B30最高十项时潜力值的值。只有B30平均值可以自己计算得出,R10在缺失时会显示为0.00。 ↩︎

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值