Skip to content

Commit 4a3c76f

Browse files
committed
feat(timing): 费用条 tick 检测(tick.py,numpy 向量化移植 CostBarRuler)
custom/core/timing/tick.py: find_cost_bar_roi / get_filled_pixel_width(普通+遮罩双模式,numpy 向量化)/ get_logical_frame(像素图命中+容差5)/ detect 便捷封装。 输入 maafw BGR ndarray;白/灰度判定通道顺序无关,BGR 直接处理。ruff 全过;结算界面截图 sanity 不崩(137,非战斗界面的误检是预期,tick 仅战斗内调用)。 顺带 ruff --fix(Optional→|None)+ 修 main.py:50 E501。
1 parent adb2b32 commit 4a3c76f

4 files changed

Lines changed: 140 additions & 1 deletion

File tree

custom/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""核心算法:费用条计时 / 几何投影 / 战斗数据 / 头像。"""

custom/core/timing/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""费用条计时:tick 检测 / 校准 / 统一时间源。"""

custom/core/timing/tick.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""费用条 tick 检测。
2+
3+
移植自 reference/ArknightsCostBarRuler-master/ruler/utils.py 的三个函数:
4+
- find_cost_bar_roi(width, height) -> (x1, x2, y)
5+
- get_filled_pixel_width(frame, roi) -> int | None(向量化)
6+
- get_logical_frame(frame, roi, pixel_map) -> int | None
7+
8+
输入 frame 为 maafw 截图返回的 BGR ndarray (H, W, 3) uint8。
9+
白/灰度判定对通道顺序不敏感(|R-G|、|G-B|、全通道>阈值),故 BGR 直接处理无需转换。
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import numpy as np
15+
16+
from custom import config
17+
18+
Roi = tuple[int, int, int] # (x1, x2, y)
19+
20+
21+
def find_cost_bar_roi(width: int, height: int) -> Roi:
22+
"""根据屏幕分辨率计算费用条 ROI。
23+
24+
参考 1920x1080(config.REF_WIDTH/HEIGHT),按短边等比缩放。
25+
"""
26+
ref_aspect = config.REF_WIDTH / config.REF_HEIGHT
27+
cur_aspect = width / height
28+
scale = height / config.REF_HEIGHT if cur_aspect >= ref_aspect else width / config.REF_WIDTH
29+
30+
x1 = width - config.X1_OFFSET_FROM_RIGHT * scale
31+
x2 = width - config.X2_OFFSET_FROM_RIGHT * scale
32+
y1 = height - config.Y1_OFFSET_FROM_BOTTOM * scale
33+
y2 = height - config.Y2_OFFSET_FROM_BOTTOM * scale
34+
return (round(x1), round(x2), round((y1 + y2) / 2))
35+
36+
37+
def get_filled_pixel_width(frame: np.ndarray, roi: Roi) -> int | None:
38+
"""提取费用条填充像素宽。
39+
40+
双模式(与 CostBarRuler 一致):
41+
- 普通模式:白像素阈值 > WHITE_THRESHOLD(250)。
42+
- 遮罩模式(变暗):> MASKED_WHITE_THRESHOLD(150) 且整体 <= MASKED_MAX_BRIGHTNESS(165)。
43+
ROI 行必须为灰度(GRAY_TOLERANCE 内);末端像素非灰度则判定 ROI 无效返回 None。
44+
45+
Returns:
46+
填充像素宽(0 表示空/未检出),或 None 表示 ROI 无效。
47+
"""
48+
x1, x2, y = roi
49+
total = x2 - x1
50+
if total <= 0:
51+
return None
52+
h, w = frame.shape[:2]
53+
if not (0 <= y < h and 0 <= x1 and x2 <= w):
54+
return None
55+
56+
row = frame[y, x1:x2].astype(np.int16) # (total, 3) BGR
57+
c0, c1, c2 = row[:, 0], row[:, 1], row[:, 2]
58+
gray = (np.abs(c0 - c1) <= config.GRAY_TOLERANCE) & (np.abs(c1 - c2) <= config.GRAY_TOLERANCE)
59+
60+
# 末端像素必须灰度,否则 ROI 无效。
61+
if not gray[-1]:
62+
return None
63+
64+
# --- 普通模式 ---
65+
white = (
66+
(c0 > config.WHITE_THRESHOLD)
67+
& (c1 > config.WHITE_THRESHOLD)
68+
& (c2 > config.WHITE_THRESHOLD)
69+
)
70+
if white[-1]:
71+
return total
72+
non_end_white = np.where(white[:-1])[0] # 右→左扫到第一个白边
73+
if non_end_white.size > 0:
74+
edge = int(non_end_white[-1])
75+
# edge 右侧(扫描经过的填充段)必须全灰度,否则 ROI 含杂色 → 无效。
76+
if gray[edge + 1 : -1].all():
77+
return edge + 1
78+
return None
79+
80+
# --- 遮罩模式回退(filled == 0)---
81+
too_bright = (
82+
(c0 > config.MASKED_MAX_BRIGHTNESS)
83+
| (c1 > config.MASKED_MAX_BRIGHTNESS)
84+
| (c2 > config.MASKED_MAX_BRIGHTNESS)
85+
)
86+
if too_bright[-1]:
87+
return 0 # 末端过亮,不可能是遮罩模式。
88+
masked_white = (
89+
(c0 > config.MASKED_WHITE_THRESHOLD)
90+
& (c1 > config.MASKED_WHITE_THRESHOLD)
91+
& (c2 > config.MASKED_WHITE_THRESHOLD)
92+
)
93+
if masked_white[-1]:
94+
return total
95+
candidates = np.where(masked_white[:-1] & ~too_bright[:-1])[0]
96+
if candidates.size > 0:
97+
edge = int(candidates[-1])
98+
seg_gray = gray[edge + 1 : -1]
99+
seg_bright = too_bright[edge + 1 : -1]
100+
if seg_gray.all() and not seg_bright.any():
101+
return edge + 1
102+
return 0
103+
104+
105+
def get_logical_frame(frame: np.ndarray, roi: Roi, pixel_map: dict[str, int]) -> int | None:
106+
"""像素宽 → 逻辑帧(via 校准 pixel_map)。
107+
108+
先直接命中,否则在 PIXEL_TOLERANCE(5) 内取最近。未命中返回 None。
109+
"""
110+
pw = get_filled_pixel_width(frame, roi)
111+
if pw is None:
112+
return None
113+
key = str(pw)
114+
if key in pixel_map:
115+
return pixel_map[key]
116+
best_frame: int | None = None
117+
best_diff = config.PIXEL_TOLERANCE + 1
118+
for k, v in pixel_map.items():
119+
diff = abs(pw - int(k))
120+
if diff < best_diff:
121+
best_diff = diff
122+
best_frame = v
123+
return best_frame if best_diff <= config.PIXEL_TOLERANCE else None
124+
125+
126+
def detect(frame: np.ndarray, pixel_map: dict[str, int] | None = None) -> tuple[Roi, int | None]:
127+
"""便捷:算 ROI + 取填充宽(+ 可选逻辑帧)。
128+
129+
Returns:
130+
(roi, logical_frame or None);若 pixel_map 为 None 则第二项为填充像素宽。
131+
"""
132+
h, w = frame.shape[:2]
133+
roi = find_cost_bar_roi(w, h)
134+
if pixel_map is None:
135+
return roi, get_filled_pixel_width(frame, roi)
136+
return roi, get_logical_frame(frame, roi, pixel_map)

custom/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ def pick_win32_controller(): # type: ignore[no-untyped-def]
4747
break
4848
if target is None:
4949
target = windows[0]
50-
logger.info("no Arknights/emulator window matched; using first: %r", getattr(target, "window_name", ""))
50+
name = getattr(target, "window_name", "")
51+
logger.info("no Arknights/emulator window matched; using first: %r", name)
5152

5253
return Win32Controller(
5354
hWnd=target.hwnd,

0 commit comments

Comments
 (0)