-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathmaiMuriDetector.py
511 lines (454 loc) · 23.1 KB
/
maiMuriDetector.py
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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
"""
Author: moying
Date: 2021-08-16 19:37:56
LastEditTime: 2021-08-17 02:07:36
LastEditors: Please set LastEditors
Description: maimai muri detection
FilePath: \maimai无理检测\maiMuriDetect.py
"""
import json
import re
from slide_time import SLIDE_TIME
"""
maimai创作谱面无理配置检测
目前只检测两种情况:
1、多押
2、slide撞尾
对于多押检测,原理较简单,若某一时间点出现三个操作,则认为是无理。
但检测中有一些特殊情况需要说明:
1、slide在检测中会被认为需要全程跟随,也即锁手,或类似于hold的操作。换言之,一个时长非常长的slide中出现了双押则认为是无理;
2、hold、slide开始和结束的瞬间都会被认为是一次操作。也即,如果有某个瞬间,一个hold刚刚结束,而出现了另一对双押,则也认为是无理;
由于协宴和部分宴谱会出现多押,所以也可以关闭多押检测
对于slide撞尾,指的是slide在完成的时候,会触碰到tap、hold、slide-tap的情况。可以分为中途撞无理和尾撞无理。
slide有以下这些类型:
直线 :F-E[x:y],
v形 :FvE[x:y],
pq形 :FpE[x:y],FqE[x:y],
sz形 :FsE[x:y],FzE[x:y],
弧形 :F^E[x:y],F<E[x:y],F>E[x:y],
ppqq形:FppE[x:y],FqqE[x:y],
转折形:FVRE[x:y],
wifi :FwE[x:y],
中途撞只会发生在: 弧形(^ < >)、部分ppqq型、转折型 中
弧形几乎全程都可能撞
部分ppqq型以及转折型可能在中途经过A区
尾撞会发生在所有slide中
slide完成时,最后一个判定区总是一个A区,若该A区有note则会导致该note被错误触发。
另外,未来还会加入蹭slide检测,若一个slide在应当完成前,其所有需要经过的判定区都被触碰过一次,就会被判为蹭slide无理
"""
def removeListCondition(iterObj, func):
"""根据func的返回值删除对象,func返回值为True时,删除该元素"""
for i in range(len(iterObj) - 1, -1, -1):
if func(iterObj[i]):
iterObj.pop(i)
def notePos(pos, relative):
"""
@params relative:bool 若为True 则pos应当是0到7的相对键位 反之则为1-8的绝对键位
将传入的非正规的键位位置转化为正规的键位位置
"""
if relative:
if pos < 0:
pos += 8
if pos > 7:
pos %= 8
else:
if pos <= 0:
pos += 8
if pos > 8:
pos = (pos - 1) % 8 + 1
return pos
class MaiMuriDetector:
def __init__(self, path):
try:
with open(path, "r", encoding="utf-8") as f:
temp = json.load(f)
self.data = temp["timingList"]
self.infos = {
"level": temp["level"],
"difficulty": temp["difficulty"],
"title": temp["title"],
"artist": temp["artist"],
"designer": temp["designer"],
}
assert isinstance(self.data, list)
except:
raise RuntimeError(f"无法读取{path}, 错误的编码或文件格式.")
def multNoteDetect(self, eps=5):
"""
@params eps:float 操作时间结算的精度 单位为小数点后位
根据原先操作序列生成操作序列
tap会生成一个开始时间和结束时间相同的操作
hold会生成一个开始时间和结束时间不同的操作
slide会生成一个tap和一个开始时间和结束时间不同的操作
同时 每个操作还会记录这次操作的开始位置和结束位置
tap和hold的开始位置和结束位置都相同
slide的开始位置和结束位置可能不同
这个位置是用于处理一笔画、拍滑等问题的
生成操作序列后 按照开始时间正向排序
如果时间相同 则按照tap, hold, slide的顺序排序
遍历操作序列 同时维护一个列表 存储“正在操作”的行为
每次循环:
维护列表 删除所有结束时间小于当前时间的行为 时间相等的行为不删除
如果当前读取的操作时tap操作 则遍历“正在操作”的行为 如果有时间相同且startArea相同的行为 则删除它
如果当前读取的操作是slide操作 则遍历“正在操作”的行为 找出结束时间与当前时间相同的行为:
如果这个行为的结束位置 和 当前行为的开始位置相等 则删除这个行为(即这个行为和当前行为合并了)
将当前操作加入列表中
检查列表的大小 若大于2则报错并输出错误信息
opSequence List<Dict>
{
'startTime': float,
'endTime': float,
'startArea': int,
'endArea': int,
'type': int, # 0-tap 1-slide-tap 2-hold 3-slide
'noteContent': str,
'position': tuple<int, int>
}
"""
epsRound = lambda x: round(x, eps)
prog = re.compile(r"(\d)(.+?)(\d{1,2})\[\d+?\:\d+?\]")
errorCnt = 0
opSequence = []
for noteGroup in self.data:
baseTime = noteGroup["time"] # 这一组note的时间
position = noteGroup["rawTextPositionX"], noteGroup["rawTextPositionY"]
for note in noteGroup["noteList"]:
if note["noteType"] == 0:
# tap
opSequence.append(
{
"startTime": epsRound(baseTime),
"endTime": epsRound(baseTime),
"startArea": note["startPosition"],
"endArea": note["startPosition"],
"type": 0,
"noteContent": note["noteContent"],
"position": position,
}
)
elif note["noteType"] == 1:
# slide
# slide-tap 星星头
opSequence.append(
{
"startTime": epsRound(baseTime),
"endTime": epsRound(baseTime),
"startArea": note["startPosition"],
"endArea": note["startPosition"],
"type": 1,
"noteContent": note["noteContent"],
"position": position,
}
)
try:
temp = prog.match(note["noteContent"])
endPosition = int(temp.group(3)[-1])
except:
print(f"[语法错误] \"{note['noteContent']}\"({position[1]+1}L,{position[0]+1}C)解析失败,可能存在语法错误")
continue
opSequence.append(
{
"startTime": epsRound(note["slideStartTime"]),
"endTime": epsRound(note["slideStartTime"] + note["slideTime"]),
"startArea": note["startPosition"],
"endArea": endPosition,
"type": 3,
"noteContent": note["noteContent"],
"position": position,
}
)
elif note["noteType"] == 2:
# hold
opSequence.append(
{
"startTime": epsRound(baseTime),
"endTime": epsRound(baseTime + note["holdTime"]),
"startArea": note["startPosition"],
"endArea": note["startPosition"],
"type": 2,
"noteContent": note["noteContent"],
"position": position,
}
)
# 按照(时间,类型)顺序正向排序
opSequence.sort(key=lambda x: (x["startTime"], x["type"]))
inHandling = []
for op in opSequence:
removeListCondition(inHandling, lambda x: x["endTime"] < op["startTime"]) # 删除结束时间小于当前时间的
if op["type"] == 3:
# 如果是slide 就要考虑是否会有拍滑、hold滑、一笔画 若有 则需要合并这些操作
removeListCondition(
inHandling, lambda x: x["endTime"] == op["startTime"] and x["endArea"] == op["startArea"]
)
if op["type"] == 1:
# 如果是slide-tap 就要考虑是不是有重合星星起点
removeListCondition(
inHandling,
lambda x: x["type"] == 1
and x["startTime"] == op["startTime"]
and x["startArea"] == op["startArea"],
)
inHandling.append(op.copy())
if len(inHandling) > 2:
# 多押
print(f"[多押无理] ", end="")
for e in inHandling:
if e['type'] == 1:
print('*', end='')
print(f"\"{e['noteContent']}\"({e['position'][1]+1}L,{e['position'][0]+1}C) ", end="")
print(f"可能形成了{len(inHandling)}押")
errorCnt += 1
return errorCnt
def slideDetect(self, judgementLength=0.15):
"""
@params judgementLength:float 判定时长,指tap判定为good的时间界限
SLIDE_TIME中数据通过MajView生成谱面预览并使用PR逐帧慢放计算得出,不代表官方数据,不保证正确性
"""
"""
opSequence List<Dict>
{
'time': float, # 操作时间
'area': int, # 操作区域(A区)
'type': int # 操作类型 0-tap 1-slideTrack
}
"""
prog = re.compile(r"(\d)(.+?)(\d{1,2})\[\d+?\:\d+?\]")
opSequence = []
for noteGroup in self.data:
baseTime = noteGroup["time"] # 这一组note的时间
position = noteGroup["rawTextPositionX"], noteGroup["rawTextPositionY"]
for note in noteGroup["noteList"]:
if note["noteType"] == 0 or note["noteType"] == 2:
# tap or hold
# 这里无论是tap还是hold都是一样的 因为只是检测是否会蹭到开始的判定 所以hold可以视为tap
opSequence.append(
{
"time": baseTime,
"area": note["startPosition"],
"type": 0,
"noteContent": note["noteContent"],
"position": position,
}
)
elif note["noteType"] == 1:
# slide
"""
通过noteContent匹配出slide类型、起点、终点
在SLIDE_TIME中找到对应类型的slide时间数据
将终点减去起点,得到相对终点,带入上面的dict得到时间比例
将时间比例乘以slideTime得到对应进入A区的相对时间
将进入A区相对时间加上slideStartTime即可得到进入A区的真实时间
另外,经过的A区编号在SLIDE_TIME中也是相对位置,需要加上起点才能得到绝对位置
"""
try:
slideInfos = prog.match(note["noteContent"])
sStart = int(slideInfos.group(1))
sType = slideInfos.group(2)
sEnd = slideInfos.group(3)
except:
print(f"[语法错误] \"{note['noteContent']}\"({position[1]+1}L,{position[0]+1}C)解析失败,可能存在语法错误")
continue
# 处理转折型(aVbc)特殊情况
if sType == "V":
sEnd = (notePos(int(sEnd[0]) - sStart, True), notePos(int(sEnd[1]) - sStart, True))
else:
sEnd = notePos(int(sEnd) - sStart, True) # 相对终点
if sType == ">" and sStart in (3, 4, 5, 6): # 数据中的>总是顺时针 若真实数据为逆时针 则需要反转
"""
WARNING:
这其实是一个测定数据时的遗留问题
在测定数据的时候,对于每一种slide,都以1开头来测定,并存储相对的位置
在实际判定的时候,会根据实际的起点和相对位置计算绝对位置,也就是说,是在测定数据的基础上进行了旋转
但是>和<型的slide,其方向会受到起点位置的影响
以>为例,当起点是7812时,是顺时针,起点是3456时,则为逆时针
但是在测定时,因为起点总是1,所以>总是顺时针的,<总是逆时针的
--- 换言之,在SLIDE_TIME里,>不表示向右开始回旋的slide,而表示“总是顺时针的回旋slide” ---
所以此处选择对>和<slide进行特判,如果和测定时的方向相反,则人为反转操作符
请注意:这是目前的权宜之计,也许后续会更正这个问题
"""
# 当起点为3456 slide类型为>时 和测定方向相反
sType = "<"
elif sType == "<" and sStart in (3, 4, 5, 6):
# 当起点为3456 slide类型为<时 和测定方向相反
sType = ">"
try:
sTimeInfo = SLIDE_TIME[sType][sEnd]
except:
print(f"[语法错误] \"{note['noteContent']}\"({position[1]+1}L,{position[0]+1}C)解析失败,可能存在语法错误")
continue
for each in sTimeInfo:
opSequence.append(
{
"time": each["time"] * note["slideTime"] + note["slideStartTime"],
"area": notePos(each["area"] + sStart, False),
"type": 1,
"noteContent": note["noteContent"],
"position": position,
}
)
"""
生成操作序列后,需要开始判定无理
无理满足以下情况:
1、一个slideTrack操作与一个tap操作发生发生在同一个area中
2、slideTrack操作先于tap操作 # 必须是slide撞上了未发生的tap(蹭只可能是fast) 如果反过来 则应该是判断是否为蹭
3、二者时间间隔小于judgementLength
"""
opSequence.sort(key=lambda x: (x["time"], -x["type"]))
errorCnt = 0
inJudgement = [] # 正在判定的slide操作
for op in opSequence:
curTime = op["time"]
# 删除curTime-judgementLength之前的待判定操作 因为这些操作不再可能被判无理了
removeListCondition(inJudgement, lambda x: x["time"] + judgementLength < curTime)
if op["type"] == 1:
# 将slide操作加入待判定操作列表中
inJudgement.append(op)
elif op["type"] == 0:
# tap操作
for e in inJudgement:
if e["area"] == op["area"] and op["time"] - judgementLength < e["time"] < op["time"]:
# 无理
print(
f"""[撞尾无理] "{e['noteContent']}"({e['position'][1]+1}L,{e['position'][0]+1}C)可能会撞上 \
"{op['noteContent']}\"({op['position'][1]+1}L,{op['position'][0]+1}C) 二者间隔{int((op['time']-e['time'])*1000)}ms"""
)
errorCnt += 1
return errorCnt
def detectMuri(self, multNoteDetectEnable=True, slideDetectAccuracy=0.15):
"""检测"""
print(
f"""【谱面信息】
{self.infos['title']} - {self.infos['artist']}
{self.infos['difficulty']} lv.{self.infos['level']}
note designed by {self.infos['designer']}
"""
)
print("【开始检查谱面】\n")
if multNoteDetectEnable:
multNoteErrorCnt = self.multNoteDetect(5)
if multNoteErrorCnt == 0:
print("【未检测到多押无理配置】")
print()
slideErrorCnt = self.slideDetect(slideDetectAccuracy)
if slideErrorCnt == 0:
print("【未检测到撞尾无理配置】")
print()
if multNoteDetectEnable:
print(f"检查完毕,共发现{multNoteErrorCnt}个多押无理错误,{slideErrorCnt}个撞尾无理错误")
else:
print(f"检查完毕,共发现{slideErrorCnt}个撞尾无理错误")
print("\n>>>maimaiMuriDetector提供的警告与建议并不一定完全准确,结果仅供参考<<<")
if __name__ == "__main__":
import sys
import os
import getopt
def getOptByName(opts, opt):
for optName, optValue in opts:
if optName in opt:
return optValue
return None
opts, args = getopt.getopt(
sys.argv[1:],
"hicm:s:",
["help", "interactive", "command-line", "mult-note-detection=", "slide-detection-accuracy="],
)
if len(opts) == 0 or getOptByName(opts, ("-h", "--help")) is not None:
# 显示帮助信息并退出
print(
r"""帮助信息
maimaiMuriDetector [-h] [-i] [-c] [-m [-s]] [filepath]
-h --help 显示本帮助信息
-i --interactive 适合电脑苦手的方式 通过交互式的命令菜单来使用maimaiMuriDetector
-c --command-line 适合电脑老手的方式 通过命令行来使用maimaiMuriDetector
既不传入-i 也不传入-c时 默认工作在命令行模式下
-m --mult-note-detection
指定是否开启多押无理检测 默认为开 传入f/0/false可禁用多押无理检测
如果您的谱面是协宴 或存在多押的宴谱 建议您关闭多押无理检测
-s --slide-detection-accuracy
设置撞尾检测的时长 默认为150(good的判定区间) 单位为毫秒 该设置不建议低于默认值
如果您希望更严格的检测撞尾无理 可以适当提高该设置
filepath 指定需要检测的majdata.json路径 使用-c选项时 必须输入此参数
如果您的路径中含有空格 请您用半角引号(英文引号)将路径括起 或者您也可以直接将文件拖拽至shell窗口中自动生成路径
·如何获得majdata.json:
使用MajdataEdit打开您的谱面文件,切换到需要检测的难度,单击播放或“录制模式”。此时MajdataEdit会在您的谱面文件夹中生成majdata.json
请注意:
majdata.json中只能存放一个难度的谱面信息。
举例来说:如果您查看过MASTER谱面之后,又切换到EXPERT难度并点击播放或“录制模式”,那么谱面文件夹中的majdata.json中的内容就会变为EXPERT谱面的信息。这一点请您一定要多加注意
命令示例:
maimaiMuriDetector -h
maimaiMuriDetector -i
maimaiMuriDetector --interactive
maimaiMuriDetector -c D:\maimai自制谱\tempestissimo\majdata.json
maimaiMuriDetector -c -m false ".\[宴]Oshama Scramble\majdata.json"
maimaiMuriDetector --slide-detection-accuracy=200 majdata.json
maimaiMuriDetector majdata.json"""
)
sys.exit(0)
else:
interactive = False
if getOptByName(opts, ("-i", "--interactive")) is not None:
# 交互式
interactive = True
if getOptByName(opts, ("-c", "--command-line")) is not None:
if interactive:
print("错误的选项: 同时使用了-i和-c选项")
sys.exit(1)
interactive = False
multNoteDetection = getOptByName(opts, ("-m", "--mult-note-detection"))
if multNoteDetection is not None:
if interactive:
print("错误的选项: 同时使用了-i和-m选项")
sys.exit(1)
interactive = False
multNoteDetection = False if multNoteDetection in ("f", "0", "false") else True
else:
multNoteDetection = True
slideDetectionAccuracy = getOptByName(opts, ("-s", "--slide-detection-accuracy"))
if slideDetectionAccuracy is not None:
if interactive:
print("错误的选项: 同时使用了-i和-s选项")
sys.exit(1)
interactive = False
try:
slideDetectionAccuracy = int(slideDetectionAccuracy) / 1000
except:
print("错误的参数: -s的值必须是一个整数")
sys.exit(2)
else:
slideDetectionAccuracy = 0.15
if not interactive:
if len(args) == 0:
print("错误的参数: 使用命令行模式 但没有给出filepath")
sys.exit(2)
if len(args) != 1:
print("错误的参数: 给出了多个filepath 或有参数输入有误")
sys.exit(2)
if not os.path.exists(args[0]):
print("错误的参数: 给出的filepath不存在")
sys.exit(2)
if interactive:
# 交互模式
filepath = input("请输入majdata.json的路径(也可将文件拖拽至该窗口):")
if not os.path.exists(filepath):
print("majdata.json路径错误 请检查输入并重试")
sys.exit(2)
multNoteDetection = input("是否开启多押无理检测\n\t如果您的谱面是协宴或允许多押的宴谱 您可以禁用多押检测\n\t(y/n 默认y):") != "n"
slideDetectionAccuracy = input(
"撞尾检测精度\n\t单位ms 默认值150\n\t默认值是最低限度的撞尾检测 如果您想加大检测力度 可以适当提高此值\n\t(直接输入回车或输入非数字视为使用默认值):"
)
try:
slideDetectionAccuracy = int(slideDetectionAccuracy) / 1000
except:
slideDetectionAccuracy = 0.15
print("\n\n")
mmd = MaiMuriDetector(filepath)
mmd.detectMuri(multNoteDetection, slideDetectionAccuracy)
else:
# 命令行模式
mmd = MaiMuriDetector(args[0])
mmd.detectMuri(multNoteDetection, slideDetectionAccuracy)
try:
# 因为不知道是什么平台的,总之尝试pause一下,如果是linux的大概率是在shell里运行的,不pause也无所谓
os.system("pause")
except:
pass