Skip to content

Commit 26139a3

Browse files
committed
feat(timing): 边界周期修正 + 负费检测 + 录制回放回归框架
边界周期修正 (time_source.py): - 开局 315 逻辑帧识别边界周期(左闭右闭),该周期多 1 帧(30→31) - 满条端点检测:pixel_map 不含满条宽度,边界/后周期手动覆盖 - 边界后 hidden 帧跳过:校准检测到的隐藏辉光帧在左开右闭不存在 - per_cycle 帧偏移对齐 rust 重写版 负费检测(tick.py + time_source.py): - detect_negative_cost(): COST_SIGN_ROI 内最长纯白横条 ≥12px 判定减号 - time_source 负费相位重投射(周期 ×2),不叠加边界修正 录制回放回归: - tools/screencap.py: Win32 无间隔轮询, BMP 存盘 → 批量转 PNG - tests/replay/: 帧序列 → TimeSource → 比对 expected.json(frames + per_cycle) - calibration.from_dict() 支持自包含加载 - 边界周期用例: 367 帧(覆盖满条→边界后hidden skip)
1 parent 9f51685 commit 26139a3

380 files changed

Lines changed: 1259 additions & 9 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

aao/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,9 @@ def _reconnect(self, skip_overlay: bool = False) -> bool:
304304
self.overlay = OverlayWindow()
305305
self.overlay.show()
306306
self.worker = MeasurementWorker(
307-
controller, self._calibration_data, profile_name=self._profile_name
307+
controller,
308+
self._calibration_data,
309+
profile_name=self._profile_name,
308310
)
309311
self.worker_thread = QThread()
310312
self.worker.moveToThread(self.worker_thread)

aao/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@
4141
GRAY_TOLERANCE = 20
4242
PIXEL_TOLERANCE = 5 # frame-map nearest-match tolerance
4343

44+
# --- Negative-cost (可露希尔) minus-sign detection ---
45+
# maafw 截图统一缩放到 1280x720,故减号 ROI 用固定坐标(无需按分辨率换算)。
46+
# 减号是费用数字前的一段集中横向纯白 run;正数字笔画不会在这条窄横条内产生长 run。
47+
COST_SIGN_ROI = (1214, 531, 27, 9) # (x, y, w, h) @ 1280x720
48+
COST_SIGN_MIN_RUN = 12 # 触发判定的最小连续纯白像素数(实测减号 ≈19px)
49+
4450
# --- Timing (frames / ms) ---
4551
FRAMES_PER_SECOND = 30
4652
TICK_MAX_DEFAULT = 30 # 1s = 30 ticks

aao/core/timing/calibration.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from dataclasses import asdict, dataclass
1616
from pathlib import Path
1717
from statistics import median
18+
from typing import Any
1819

1920
import numpy as np
2021

@@ -240,9 +241,8 @@ def save(data: FullCalibrationData, basename: str) -> str:
240241
return filename
241242

242243

243-
def load(filename: str) -> FullCalibrationData:
244-
path = calibration_dir() / filename
245-
raw = json.loads(path.read_text(encoding="utf-8"))
244+
def from_dict(raw: dict[str, Any]) -> FullCalibrationData:
245+
"""从已解析的校准 dict 构造 FullCalibrationData(供 replay 等自包含场景使用)。"""
246246
profiles = [
247247
CalibrationProfile(total_frames=p["total_frames"], pixel_map=p["pixel_map"])
248248
for p in raw["profiles"]
@@ -256,6 +256,11 @@ def load(filename: str) -> FullCalibrationData:
256256
)
257257

258258

259+
def load(filename: str) -> FullCalibrationData:
260+
path = calibration_dir() / filename
261+
return from_dict(json.loads(path.read_text(encoding="utf-8")))
262+
263+
259264
def list_files() -> list[str]:
260265
return sorted(p.name for p in calibration_dir().glob("*.json"))
261266

aao/core/timing/tick.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,43 @@ def get_logical_frame(frame: np.ndarray, roi: Roi, pixel_map: dict[str, int]) ->
149149
return best_frame if best_diff <= config.PIXEL_TOLERANCE else None
150150

151151

152+
def detect_negative_cost(frame: np.ndarray) -> bool:
153+
"""检测费用是否为负(可露希尔负费)。
154+
155+
减号是费用数字前的一段集中横向纯白 run(实测 ≈19px);正数字笔画不会在
156+
这条窄横条 ROI 内产生同等长度的连续纯白 run。在 ``COST_SIGN_ROI`` 内逐行扫描,
157+
任一行的最长连续纯白 run ≥ ``COST_SIGN_MIN_RUN`` 即判定为负费。
158+
159+
maafw 截图统一缩放到 1280x720,故 ROI 用固定坐标。
160+
161+
Args:
162+
frame: BGR ndarray (H, W, 3) uint8。
163+
164+
Returns:
165+
True 表示检测到减号(负费)。
166+
"""
167+
x, y, w, h = config.COST_SIGN_ROI
168+
fh, fw = frame.shape[:2]
169+
if x < 0 or y < 0 or x + w > fw or y + h > fh:
170+
return False
171+
172+
region = frame[y : y + h, x : x + w].astype(np.int16) # (h, w, 3) BGR
173+
white = (region > config.WHITE_THRESHOLD).all(axis=2) # (h, w) bool
174+
175+
# 逐行求最长连续纯白 run:对每行做行内累计,遇非白清零,取全局最大。
176+
for row in white: # pyright: ignore[reportGeneralTypeIssues]
177+
if not row.any():
178+
continue
179+
# 累计连续 True 长度:cumsum 在非白处“断点”归零的经典向量化写法。
180+
idx = np.arange(len(row))
181+
# 每个位置减去“上一个非白位置”,得到当前连续白的长度。
182+
last_false = np.where(row, 0, idx)
183+
max_run = int((idx - np.maximum.accumulate(last_false)).max())
184+
if max_run >= config.COST_SIGN_MIN_RUN:
185+
return True
186+
return False
187+
188+
152189
def detect(frame: np.ndarray, pixel_map: dict[str, int] | None = None) -> tuple[Roi, int | None]:
153190
"""便捷:算 ROI + 取填充宽(+ 可选逻辑帧)。
154191

aao/core/timing/time_source.py

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
RESET_TIMEOUT_S = 1.5
2626
_WRAP_MARGIN = 3 # 帧下降超过此值 → 判定周期 wrap(过滤抖动)
27+
_BOUNDARY_SWITCH_FRAME = 315 # 开局第 315 逻辑帧所在周期为边界周期
2728

2829

2930
def format_timer(total_frames: int, fps: int = config.FRAMES_PER_SECOND) -> str:
@@ -37,6 +38,26 @@ def format_timer(total_frames: int, fps: int = config.FRAMES_PER_SECOND) -> str:
3738
return f"{minutes:02d}:{seconds:02d}:{frames:02d}"
3839

3940

41+
def _compute_boundary_cycle(
42+
calibration: FullCalibrationData,
43+
switch_frame: int = _BOUNDARY_SWITCH_FRAME,
44+
) -> int:
45+
"""计算 boundary_switch_frame 落在哪个周期(0-indexed)。
46+
47+
从 cycle 0 起累加各 profile 的 total_frames,目标帧落入的 cycle 即为边界周期。
48+
"""
49+
elapsed = 0
50+
num = len(calibration.profiles)
51+
if num == 0:
52+
return 0
53+
for i in range(100): # 安全上限
54+
total = calibration.profiles[i % num].total_frames
55+
if switch_frame < elapsed + total:
56+
return i
57+
elapsed += total
58+
return 0
59+
60+
4061
class TimeSource:
4162
"""费用条时间状态机 + PLL 外推。喂帧驱动,产出周期帧 / 全局计时器。"""
4263

@@ -52,6 +73,18 @@ def __init__(self, calibration: FullCalibrationData, reset_timeout: float = RESE
5273
self.last_detect_time = 0.0
5374
self._total_frames = 0
5475

76+
# 负费(可露希尔)状态
77+
self._cost_is_negative = False
78+
79+
# 边界周期:开局第 315 逻辑帧所在的周期(0-indexed),
80+
# 该周期多 1 帧(左闭右闭 vs 左闭右开)。
81+
# 负费时不叠加边界修正(回费速率不同,边界位置尚未验证)。
82+
self._boundary_cycle_index = _compute_boundary_cycle(calibration)
83+
84+
# 每档 profile 在边界前校准时检测到的隐藏帧数。
85+
# 边界后(左开右闭)这些隐藏帧不存在,需从显示中跳过。
86+
self._num_hidden = [p.total_frames - len(p.pixel_map) for p in calibration.profiles]
87+
5588
# PLL 外推状态
5689
self._prev_measured = -1 # 上次测量帧(用于 wrap 检测)
5790
self._stuck_lf: int | None = None # 卡住时的测量帧
@@ -71,9 +104,50 @@ def update(self, frame: np.ndarray) -> int | None:
71104
"""喂一帧,更新状态。返回当前周期内逻辑帧(含 PLL 外推,None = 未检出)。"""
72105
roi = tick.find_cost_bar_roi(frame.shape[1], frame.shape[0])
73106
profile = self.active_profile
107+
total_this = profile.total_frames
108+
109+
# 负费(可露希尔)检测:每帧判定,适应技能开关。
110+
self._cost_is_negative = tick.detect_negative_cost(frame)
111+
effective_total = total_this * 2 if self._cost_is_negative else total_this
112+
74113
lf_measured = tick.get_logical_frame(frame, roi, profile.pixel_map)
75114
now = time.time()
76115

116+
# 负费相位重投射:pixel_map 按正常速率校准(30f/费),负费时回费减半(60f/费),
117+
# 需把校准帧当相位重投射到翻倍周期。
118+
if lf_measured is not None and self._cost_is_negative and total_this > 0:
119+
phase = lf_measured / total_this
120+
lf_measured = round(phase * (effective_total - 1))
121+
122+
# 边界后周期去掉 hidden 帧:校准在慢速下检测到的隐藏辉光帧(如 frame 1
123+
# 无独立宽度)在边界后左开右闭周期里不存在,需从帧序列中跳过。
124+
# frame 0(0% 起点)不动,避免超时清空。
125+
# 必须在满条修正之前执行,否则 endpoint 值会被误减。
126+
if (
127+
lf_measured is not None
128+
and lf_measured > 0
129+
and not self._cost_is_negative
130+
and self.cycle_counter > self._boundary_cycle_index
131+
):
132+
nh = profile.total_frames - len(profile.pixel_map)
133+
if nh > 0:
134+
if lf_measured <= nh:
135+
lf_measured = None # hidden 帧,跳过
136+
else:
137+
lf_measured -= nh # 可见帧,回正到无 hidden 的序号
138+
139+
# 满条修正:pixel_map 按左闭右开校准,不含满条宽度。满条像素宽可能
140+
# 被 nearest-match 误判为 frame 29(容差 5 内),需在端点周期里覆盖。
141+
# 需在 None 检查之前执行,否则永远不触发。
142+
if not self._cost_is_negative:
143+
pw = tick.get_filled_pixel_width(frame, roi)
144+
bar_w = roi[1] - roi[0]
145+
if pw is not None and pw >= bar_w:
146+
if self.cycle_counter == self._boundary_cycle_index:
147+
lf_measured = total_this # 边界周期:满条 → frame 30
148+
elif self.cycle_counter > self._boundary_cycle_index:
149+
lf_measured = total_this - 1 # 之后周期:满条 → frame 29
150+
77151
if lf_measured is None:
78152
self.previous_frame = -1
79153
# 不重置 _prev_measured:保留上次有效值,以便 wrap 瞬间的短暂 None 后仍能检测周期
@@ -84,8 +158,6 @@ def update(self, frame: np.ndarray) -> int | None:
84158
self._reset_state()
85159
return None
86160

87-
total_this = profile.total_frames
88-
89161
# --- wrap 检测(基于测量帧)---
90162
wrapped = False
91163
if self._prev_measured >= 0 and lf_measured < self._prev_measured - _WRAP_MARGIN:
@@ -97,7 +169,10 @@ def update(self, frame: np.ndarray) -> int | None:
97169
else:
98170
self._cycle_period = period
99171
self._last_wrap_time = now
100-
self.cycle_base_frames += total_this
172+
inc = effective_total
173+
if not self._cost_is_negative and self.cycle_counter == self._boundary_cycle_index:
174+
inc += 1 # 边界周期(左闭右闭),多 1 帧
175+
self.cycle_base_frames += inc
101176
self.cycle_counter += 1
102177
wrapped = True
103178
self._stuck_lf = None
@@ -150,6 +225,7 @@ def _reset_state(self) -> None:
150225
self._stuck_since = 0.0
151226
self._last_wrap_time = 0.0
152227
self._cycle_period = 0.0
228+
self._cost_is_negative = False
153229

154230
# --- 查询 ---
155231

@@ -167,14 +243,19 @@ def is_running(self) -> bool:
167243

168244
@property
169245
def total_frames_in_cycle(self) -> int:
170-
return self.active_profile.total_frames
246+
base = self.active_profile.total_frames
247+
if self._cost_is_negative:
248+
return base * 2
249+
if self.cycle_counter == self._boundary_cycle_index:
250+
return base + 1
251+
return base
171252

172253
@property
173254
def timer_str(self) -> str:
174255
return format_timer(self._total_frames)
175256

176257
def display(self, mode: str = "0_to_n-1") -> tuple[str, str]:
177-
total = self.active_profile.total_frames
258+
total = self.total_frames_in_cycle
178259
dt = total - 1 if mode == "0_to_n-1" else total
179260
lf = self.previous_frame
180261
if lf < 0:

tests/minus.png

719 KB
Loading

tests/plus.png

722 KB
Loading

tests/replay/cases/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# 回放用例素材
2+
3+
每个子目录是一个回放用例,结构:
4+
5+
```plaintext
6+
<case_name>/
7+
frames/ 逐帧 PNG,命名 000001.png、000002.png …(字典序 = 时间序)
8+
calibration.json 对应校准 profile(直接复制 config/calibration/ 下的文件)
9+
expected.json {"frames": [全局帧, ...]} 每帧预期 total_elapsed_frames
10+
```
11+
12+
## 录制素材
13+
14+
1. 游戏内录一段视频(含费用条可见、正常 1x 回费)。
15+
2. 用系统 ffmpeg 抽帧为 PNG(60fps 示例):
16+
17+
```powershell
18+
ffmpeg -i input.mp4 -vf fps=60 frames/%06d.png
19+
```
20+
21+
抽帧 fps 要与测量 worker 的 `interval_s`(默认 1/60)一致,否则帧序号对不上。
22+
23+
3. 把对应关卡的校准文件复制为 `calibration.json`。
24+
25+
## 生成 expected.json
26+
27+
先跑一次回放,把实际读数作为初稿:
28+
29+
```python
30+
from pathlib import Path
31+
from replay import load_case, run_replay
32+
import json
33+
34+
frames, calib, _ = load_case(Path("cases/边界周期"))
35+
actual = run_replay(frames, calib)
36+
Path("cases/边界周期/expected.json").write_text(
37+
json.dumps({"frames": actual}), encoding="utf-8"
38+
)
39+
```
40+
41+
然后**人工核对关键帧**(边界周期第 11 周期、负费切换点),确认无误后再固化。
42+
expected 必须是「正确答案」,不是「当前实现输出」——否则回归无意义。
43+
44+
## 推荐用例
45+
46+
- `边界周期`:战斗 > 315 帧,验证第 11 周期多 1 帧、边界后周期不偏移。
47+
- `负费`:可露希尔关卡,验证负费期间周期帧数 ×2、相位重投射。
48+
- `正常回费`:基线,验证未引入回归。
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"detection_mode": "single",
3+
"profiles": [
4+
{
5+
"total_frames": 30,
6+
"pixel_map": {
7+
"0": 0,
8+
"3": 2,
9+
"7": 3,
10+
"11": 4,
11+
"14": 5,
12+
"18": 6,
13+
"22": 7,
14+
"26": 8,
15+
"29": 9,
16+
"33": 10,
17+
"37": 11,
18+
"41": 12,
19+
"44": 13,
20+
"48": 14,
21+
"52": 15,
22+
"56": 16,
23+
"59": 17,
24+
"63": 18,
25+
"67": 19,
26+
"71": 20,
27+
"74": 21,
28+
"78": 22,
29+
"82": 23,
30+
"86": 24,
31+
"89": 25,
32+
"93": 26,
33+
"97": 27,
34+
"101": 28,
35+
"104": 29
36+
}
37+
}
38+
],
39+
"screen_width": 1280,
40+
"screen_height": 720,
41+
"calibration_time": 1781787159.352668
42+
}

0 commit comments

Comments
 (0)