feat(detector): 更改检测可疑谱面函数的判断逻辑

- 新增 Threshold 枚举类,定义各种异常阈值
- 实现 too_dense 函数,检测单位时间内的物件密度是否异常
- 实现 slider_is_sus 函数,检查滑条是否存在异常
- 重构 is_suspicious_beatmap 函数,增加对不同游戏模式的检测逻辑
This commit is contained in:
chenjintang-shrimp
2025-08-15 12:42:49 +00:00
parent 5a295bd04a
commit b79b80a12c

View File

@@ -1,17 +1,19 @@
from __future__ import annotations
from enum import Enum
import math
from typing import TYPE_CHECKING
from app.config import settings
from app.database.beatmap import BannedBeatmaps
from app.log import logger
from app.models.beatmap import BeatmapAttributes
from app.models.mods import APIMod
from app.models.score import GameMode
from osupyparser import OsuFile
from osupyparser import HitObject, OsuFile
from osupyparser.osu.objects import Slider
from sqlmodel import col, exists, select
from sqlmodel import Session, col, create_engine, exists, select
from sqlmodel.ext.asyncio.session import AsyncSession
try:
@@ -266,13 +268,102 @@ def calculate_weighted_acc(acc: float, index: int) -> float:
return calculate_pp_weight(index) * acc if acc > 0 else 0.0
# 大致算法来自 https://github.com/MaxOhn/rosu-pp/blob/main/src/model/beatmap/suspicious.rs
class Threshold(int, Enum):
# 谱面异常常量
NOTES_THRESHOLD = 500000 # 除 taiko 以外任何模式的物件数量
TAIKO_THRESHOLD = 30000 # taiko 模式下的物量限制
NOTES_PER_1S_THRESHOLD = 200 # 3000 BPM
NOTES_PER_10S_THRESHOLD = 500 # 600 BPM
# 这个尺寸已经是常规游玩区域大小的 4 倍了 …… 如果不合适那另说吧
NOTE_POSX_THRESHOLD = 512 # x: [-512,512]
NOTE_POSY_THRESHOLD = 384 # y: [-384,384]
POS_ERROR_THRESHOLD = (
1280 * 50
) # 超过这么多个物件(包括滑条控制点)的位置有问题就毙掉
SLIDER_REPEAT_THRESHOLD = 5000
def too_dense(hit_objects: list[HitObject], per_1s: int, per_10s: int) -> bool:
per_1s = max(1, per_1s)
per_10s = max(1, per_10s)
for i in range(0, len(hit_objects)):
if len(hit_objects) > i + per_1s:
if hit_objects[i + per_1s].start_time - hit_objects[i].start_time < 1000:
return True
elif len(hit_objects) > i + per_10s:
if hit_objects[i + per_10s].start_time - hit_objects[i].start_time < 10000:
return True
return False
def slider_is_sus(hit_objects: list[HitObject]) -> bool:
for obj in hit_objects:
if isinstance(obj, Slider):
flag_repeat = obj.repeat_count > Threshold.SLIDER_REPEAT_THRESHOLD
flag_pos = int(
obj.pos.x > Threshold.NOTE_POSX_THRESHOLD
or obj.pos.x < 0
or obj.pos.y > Threshold.NOTE_POSY_THRESHOLD
or obj.pos.y < 0
)
for point in obj.points:
flag_pos += int(
point.x > Threshold.NOTE_POSX_THRESHOLD
or point.x < 0
or point.y > Threshold.NOTE_POSY_THRESHOLD
or point.y < 0
)
if flag_pos or flag_repeat:
return True
return False
def is_suspicious_beatmap(content: str) -> bool:
osufile = OsuFile(content=content.encode("utf-8-sig")).parse_file()
for obj in osufile.hit_objects:
if obj.pos.x < 0 or obj.pos.y < 0 or obj.pos.x > 512 or obj.pos.y > 384:
engine = create_engine(settings.database_url)
with Session(engine) as session:
banned_beatmap = session.exec(
select(BannedBeatmaps).where(
BannedBeatmaps.beatmap_id == osufile.beatmap_id
)
).first()
if banned_beatmap: # 人工黑名单榜上有名
return True
if isinstance(obj, Slider):
for point in obj.points:
if point.x < 0 or point.y < 0 or point.x > 512 or point.y > 384:
return True
if (
osufile.hit_objects[-1].start_time - osufile.hit_objects[0].start_time
> 24 * 60 * 60 * 1000
):
return True
if osufile.mode == int(GameMode.TAIKO):
if len(osufile.hit_objects) > Threshold.TAIKO_THRESHOLD:
return True
elif len(osufile.hit_objects) > Threshold.NOTES_THRESHOLD:
return True
match osufile.mode:
case int(GameMode.OSU):
return too_dense(
osufile.hit_objects,
Threshold.NOTES_PER_1S_THRESHOLD,
Threshold.NOTES_PER_10S_THRESHOLD,
) or slider_is_sus(osufile.hit_objects)
case int(GameMode.TAIKO):
return too_dense(
osufile.hit_objects,
Threshold.NOTES_PER_1S_THRESHOLD * 2,
Threshold.NOTES_PER_10S_THRESHOLD * 2,
)
case int(GameMode.FRUITS):
return slider_is_sus(osufile.hit_objects)
case int(GameMode.MANIA):
keys_per_hand = max(1, int(osufile.cs / 2))
per_1s = Threshold.NOTES_PER_1S_THRESHOLD * keys_per_hand
per_10s = Threshold.NOTES_PER_10S_THRESHOLD * keys_per_hand
return too_dense(osufile.hit_objects, per_1s, per_10s)
return False