在游戏自动化中,场景切换的黑屏 / 过场识别是非常关键的节点。很多人习惯直接套用网上的固定亮度阈值,但不同游戏、画质设置、分辨率下,明暗数值差异极大,硬抄数值很容易出现误判或漏判。
本文提供一套完整的实测标定方案:通过 AutoHotkey v2 编写的采集工具,按场景手动标记阶段,自动记录多点亮度数据,最终计算出适配你当前游戏环境的专属阈值,同时配套完整的问题排查与后续落地建议。
一、核心思路与采集工具
1.1 设计思路
全程不依赖鼠标位置,脚本按照游戏窗口比例自动生成 5 个固定采样点,每隔 100ms 自动读取一次像素亮度;你通过热键手动标记三个阶段(第一场景、黑屏过场、第二场景),脚本自动记录数据并生成 CSV 日志,最终给出候选阈值。
5 个采样点采用分散布局:左上、右上、中上、左下、右下,刻意避开中央角色、屏幕边缘黑边、上下方 UI / 字幕区域,降低单点干扰造成的误判。
1.2 完整采集脚本
新建文件 Octopath_Brightness_Calibration.ahk,复制以下代码:
#Requires AutoHotkey v2.0
#SingleInstance Force
; ============================================================
; 游戏场景明暗阈值采集工具
;
; F8 :开始/停止采集
; F9 :标记为第一场景正常画面
; F10 :标记为淡出/黑屏过场
; F11 :标记为第二场景正常画面
; F12 :显示当前5个采样点的详细数据
; Esc :退出脚本
; ============================================================
; ------------------------------------------------------------
; 基础配置
; ------------------------------------------------------------
CoordMode "Pixel", "Screen"
CoordMode "ToolTip", "Screen"
global SampleInterval := 100 ; 采样间隔:毫秒,100=每秒10次
global IsSampling := false ; 是否正在采集
global CurrentPhase := "UNMARKED" ; 当前阶段
global GameHwnd := 0 ; 游戏窗口句柄
global SamplePoints := [] ; 采样点数组
global LogFile := A_ScriptDir "\brightness_log.csv" ; 日志文件路径
global PixelMode := "" ; PixelGetColor模式:"" / "Alt" / "Slow"
; 各阶段亮度数据缓存
global Normal1Values := []
global TransitionValues := []
global Normal2Values := []
; ------------------------------------------------------------
; 启动提示
; ------------------------------------------------------------
MsgBox(
"游戏明暗阈值采集工具`n`n"
. "使用方法:`n"
. "1. 先切换到游戏窗口`n"
. "2. 按 F8 开始采集`n"
. "3. 第一场景正常移动时按 F9`n"
. "4. 准备进入场景切换时按 F10`n"
. "5. 第二场景完全显示后按 F11`n"
. "6. 再按 F8 停止并查看结果`n`n"
. "游戏不显示鼠标也不影响本脚本。"
)
; ------------------------------------------------------------
; 热键定义
; ------------------------------------------------------------
F8:: {
global IsSampling
if !IsSampling
StartSampling()
else
StopSampling()
}
F9:: {
SetPhase("NORMAL_1")
}
F10:: {
SetPhase("TRANSITION")
}
F11:: {
SetPhase("NORMAL_2")
}
F12:: {
ShowCurrentSample()
}
Esc:: {
ExitApp()
}
; ============================================================
; 开始采集
; ============================================================
StartSampling()
{
global IsSampling, CurrentPhase, GameHwnd, LogFile
global Normal1Values, TransitionValues, Normal2Values
; 捕获当前活动窗口作为游戏窗口
GameHwnd := WinExist("A")
if !GameHwnd {
MsgBox "没有找到当前活动窗口。"
return
}
; 清空历史数据
Normal1Values := []
TransitionValues := []
Normal2Values := []
; 重建采样点
BuildSamplePoints()
; 新建日志文件
if FileExist(LogFile)
FileDelete LogFile
FileAppend(
"Time,Phase,"
. "P1,P2,P3,P4,P5,"
. "Average,Minimum,Maximum`n",
LogFile,
"UTF-8"
)
CurrentPhase := "UNMARKED"
IsSampling := true
SetTimer CollectSample, SampleInterval
ToolTip(
"采集已开始`n"
. "请先按 F9 标记第一场景",
20, 20
)
}
; ============================================================
; 停止采集
; ============================================================
StopSampling()
{
global IsSampling
IsSampling := false
SetTimer CollectSample, 0
ToolTip()
ShowSummary()
}
; ============================================================
; 根据游戏窗口生成5个采样点
; ============================================================
BuildSamplePoints()
{
global GameHwnd, SamplePoints
WinGetClientPos &WindowX, &WindowY, &WindowW, &WindowH, "ahk_id " GameHwnd
if WindowW <= 0 || WindowH <= 0 {
MsgBox "无法获取游戏窗口尺寸。"
return
}
SamplePoints := []
; 左上
SamplePoints.Push({
X: Round(WindowX + WindowW * 0.20),
Y: Round(WindowY + WindowH * 0.25)
})
; 右上
SamplePoints.Push({
X: Round(WindowX + WindowW * 0.80),
Y: Round(WindowY + WindowH * 0.25)
})
; 中央偏上
SamplePoints.Push({
X: Round(WindowX + WindowW * 0.50),
Y: Round(WindowY + WindowH * 0.40)
})
; 左下
SamplePoints.Push({
X: Round(WindowX + WindowW * 0.20),
Y: Round(WindowY + WindowH * 0.70)
})
; 右下
SamplePoints.Push({
X: Round(WindowX + WindowW * 0.80),
Y: Round(WindowY + WindowH * 0.70)
})
}
; ============================================================
; 设置当前采集阶段
; ============================================================
SetPhase(NewPhase)
{
global IsSampling, CurrentPhase
if !IsSampling {
ToolTip "请先切换到游戏窗口并按 F8 开始采集", 20, 20
SetTimer HideToolTip, -1500
return
}
CurrentPhase := NewPhase
switch NewPhase {
case "NORMAL_1": Description := "第一场景正常画面"
case "TRANSITION": Description := "淡出/黑屏过场"
case "NORMAL_2": Description := "第二场景正常画面"
default: Description := NewPhase
}
ToolTip(
"当前阶段:`n" Description,
20, 20
)
}
; ============================================================
; 定时采样执行函数
; ============================================================
CollectSample()
{
global IsSampling, GameHwnd, CurrentPhase, SamplePoints
global LogFile, PixelMode
global Normal1Values, TransitionValues, Normal2Values
if !IsSampling
return
; 窗口消失则自动停止
if !WinExist("ahk_id " GameHwnd) {
StopSampling()
MsgBox "游戏窗口已经关闭。"
return
}
BrightnessValues := []
; 依次读取5个点
for Point in SamplePoints {
try {
Color := PixelGetColor(Point.X, Point.Y, PixelMode)
Brightness := ColorToBrightness(Color)
BrightnessValues.Push(Brightness)
} catch {
BrightnessValues.Push(-1)
}
}
if BrightnessValues.Length < 5
return
; 计算统计值
AverageBrightness := ArrayAverage(BrightnessValues)
MinimumBrightness := ArrayMinimum(BrightnessValues)
MaximumBrightness := ArrayMaximum(BrightnessValues)
; 按阶段存入对应数组
switch CurrentPhase {
case "NORMAL_1": Normal1Values.Push(AverageBrightness)
case "TRANSITION": TransitionValues.Push(AverageBrightness)
case "NORMAL_2": Normal2Values.Push(AverageBrightness)
}
; 写入CSV日志
CurrentTime := FormatTime(, "HH:mm:ss.") Format("{:03}", A_MSec)
FileAppend(
CurrentTime ","
. CurrentPhase ","
. BrightnessValues[1] ","
. BrightnessValues[2] ","
. BrightnessValues[3] ","
. BrightnessValues[4] ","
. BrightnessValues[5] ","
. AverageBrightness ","
. MinimumBrightness ","
. MaximumBrightness "`n",
LogFile,
"UTF-8"
)
; 左上角实时显示
ToolTip(
"阶段:" CurrentPhase
. "`n平均亮度:" AverageBrightness
. "`n最低:" MinimumBrightness " 最高:" MaximumBrightness
. "`n`nF9 第一场景"
. "`nF10 过场"
. "`nF11 第二场景"
. "`nF8 停止",
20, 20
)
}
; ============================================================
; RGB颜色转感知亮度
; ============================================================
ColorToBrightness(Color)
{
Red := (Color >> 16) & 0xFF
Green := (Color >> 8) & 0xFF
Blue := Color & 0xFF
; 人眼感知加权亮度,范围0~255
Brightness := Round(
Red * 0.2126
+ Green * 0.7152
+ Blue * 0.0722,
2
)
return Brightness
}
; ============================================================
; 数组工具函数:平均值
; ============================================================
ArrayAverage(Values)
{
Total := 0
ValidCount := 0
for Value in Values {
if Value >= 0 {
Total += Value
ValidCount += 1
}
}
if ValidCount = 0
return -1
return Round(Total / ValidCount, 2)
}
; ============================================================
; 数组工具函数:最小值
; ============================================================
ArrayMinimum(Values)
{
MinimumValue := ""
for Value in Values {
if Value < 0
continue
if MinimumValue = "" || Value < MinimumValue
MinimumValue := Value
}
if MinimumValue = ""
return -1
return MinimumValue
}
; ============================================================
; 数组工具函数:最大值
; ============================================================
ArrayMaximum(Values)
{
MaximumValue := ""
for Value in Values {
if Value < 0
continue
if MaximumValue = "" || Value > MaximumValue
MaximumValue := Value
}
if MaximumValue = ""
return -1
return MaximumValue
}
; ============================================================
; 显示当前采样点详情
; ============================================================
ShowCurrentSample()
{
global SamplePoints, PixelMode
if SamplePoints.Length = 0 {
MsgBox "尚未建立采样点,请先切换到游戏并按 F8。"
return
}
Message := ""
for Index, Point in SamplePoints {
try {
Color := PixelGetColor(Point.X, Point.Y, PixelMode)
Brightness := ColorToBrightness(Color)
Message .= (
"P" Index
. " 坐标=(" Point.X "," Point.Y ")"
. " 颜色=" Color
. " 亮度=" Brightness
. "`n"
)
} catch {
Message .= "P" Index " 读取失败`n"
}
}
MsgBox Message
}
; ============================================================
; 显示最终采集结果与阈值分析
; ============================================================
ShowSummary()
{
global Normal1Values, TransitionValues, Normal2Values, LogFile
Normal1Text := GetRangeText(Normal1Values)
TransitionText := GetRangeText(TransitionValues)
Normal2Text := GetRangeText(Normal2Values)
CandidateThresholdText := CalculateCandidateThreshold()
MsgBox(
"采集完成。`n`n"
. "第一场景范围:`n" Normal1Text
. "`n`n黑屏过场范围:`n" TransitionText
. "`n`n第二场景范围:`n" Normal2Text
. "`n`n阈值分析:`n" CandidateThresholdText
. "`n`n日志文件:`n" LogFile
)
}
; ============================================================
; 生成阶段范围描述文本
; ============================================================
GetRangeText(Values)
{
if Values.Length = 0
return "没有数据"
MinimumValue := ArrayMinimum(Values)
MaximumValue := ArrayMaximum(Values)
AverageValue := ArrayAverage(Values)
return (
"样本数:" Values.Length
. "`n最低:" MinimumValue
. "`n最高:" MaximumValue
. "`n平均:" AverageValue
)
}
; ============================================================
; 根据三组数据计算候选阈值
; ============================================================
CalculateCandidateThreshold()
{
global Normal1Values, TransitionValues, Normal2Values
if TransitionValues.Length = 0
return "没有记录黑屏过场数据,无法计算。"
if Normal1Values.Length = 0 || Normal2Values.Length = 0
return "两个正常场景的数据不完整,无法计算。"
Normal1Minimum := ArrayMinimum(Normal1Values)
Normal2Minimum := ArrayMinimum(Normal2Values)
NormalMinimum := Min(Normal1Minimum, Normal2Minimum)
TransitionMaximum := ArrayMaximum(TransitionValues)
; 数据可分离
if TransitionMaximum < NormalMinimum {
CandidateThreshold := Round(
(TransitionMaximum + NormalMinimum) / 2,
2
)
return (
"数据存在可分离区间。`n"
. "黑屏最高值:" TransitionMaximum
. "`n正常最低值:" NormalMinimum
. "`n候选阈值:" CandidateThreshold
. "`n`n亮度低于该值,可初步判定偏暗。"
. "`n亮度高于该值,可初步判定偏亮。"
)
}
; 数据重叠
return (
"正常画面和过场数据发生重叠。`n"
. "黑屏最高值:" TransitionMaximum
. "`n正常最低值:" NormalMinimum
. "`n`n暂时不要设置阈值。"
. "`n需要更换采样点,或增加连续帧和同步变暗判断。"
)
}
; ============================================================
; 隐藏提示
; ============================================================
HideToolTip()
{
ToolTip()
}
二 、采集前的环境准备
- 游戏设置:设置为窗口化或无边框窗口(方便调试,避免全屏坐标偏移),固定分辨率,关闭会频繁变化的菜单、字幕、浮动提示框。
- 游戏内站位:进入第一场景,站在即将进入第二场景的出口附近。
- 启动脚本:双击运行脚本,看完使用提示后,切回游戏窗口,保持游戏为前台激活状态。
三、分步采集操作流程
步骤 1:启动采集
确保游戏窗口处于前台,按 F8。 脚本会自动捕获当前窗口,按比例生成 5 个采样点;屏幕左上角会出现 ToolTip 显示当前阶段和实时亮度。如果游戏独占全屏看不到提示也不影响,数据会自动写入 CSV 日志。
步骤 2:采集第一场景正常亮度
按 F9 标记为「第一场景正常画面」。 控制角色在第一场景内正常活动:静止站立、转向、经过光影变化区域、靠近出口(但不要进入下一场景)。
目的:覆盖正常游戏时的亮度波动范围,避免把正常的阴影、视角变化误判为黑屏。
步骤 3:标记并采集过场黑屏
走到出口前,提前按 F10 标记为「淡出 / 黑屏过场」,然后控制角色进入出口,完整经历: 正常画面 → 开始淡出 → 接近全黑 → 黑屏加载 → 新场景开始淡入
说明:此阶段会完整包含淡出、全黑、淡入全过程,因此最高亮度会接近正常画面。第一次采集的核心目标是拿到完整的亮度变化曲线,而非直接得到完美阈值。
步骤 4:采集第二场景正常亮度
等待黑屏完全结束、淡入完成、角色可正常移动、场景亮度不再上升后,按 F11 标记为「第二场景正常画面」。 同样控制角色走动,采集不同位置、不同视角下的亮度。
步骤 5:停止采集并查看结果
按 F8 停止采集。 脚本会弹出统计窗口,展示三个阶段的亮度范围(最低、最高、平均)、样本数量,以及候选阈值或数据重叠警告;同时脚本同目录会生成 brightness_log.csv 原始日志文件。
四、采样结果有效性判断
4.1 核心判定标准
合格的阈值必须满足: 黑屏阶段的最高亮度 < 阈值 < 两个正常场景中更暗那个的最低亮度
两个区间存在清晰的分离带,阈值才有实际意义。
4.2 计算示例
假设你实测得到:
- 第一场景:72~125
- 黑屏过场:0~15
- 第二场景:48~96
两个正常场景中更低的边界是 48,黑屏的最高边界是 15,两者存在清晰间隔。 候选阈值取中间值:
候选阈值 = (15 + 48) / 2 ≈ 31
⚠️ 重要提醒:以上仅为计算示例,绝对不能直接套用到你的脚本中。阈值必须使用你自己游戏画面采集到的范围计算。
五、为什么会出现「数据重叠」,怎么处理
很多人第一次采集会提示「数据重叠」,本质原因是:把整个淡出 + 淡入过程都标记成了过场阶段,过渡阶段的亮度本来就和正常画面有重叠。
例如:
- 第一场景:60~120
- 完整过场阶段:0~105
- 第二场景:45~90
过场最高值 105,是因为淡出初期、淡入末期画面还没充分变暗 / 变亮。这不是采集失败,而是说明一个重要结论: 完整过场不能只用一个瞬时亮度阈值来定义。
对应的优化思路: 正式自动化时,不能只看「平均亮度低于阈值」,要叠加多重判定:
- 多个采样点亮度同步下降 / 上升
- 连续若干帧都保持在暗 / 亮区间
- 检测到变暗后,等待画面重新稳定再判定场景就绪
六、采样点与读取异常排查
6.1 怎么判断采样点好不好
脚本默认按窗口比例自动选点,无需鼠标。想确认点位可以切回桌面按 F12,查看每个点的坐标、颜色、亮度;也可以直接打开 CSV 查看 P1~P5 五列数据。
- ✅ 好的采样点:进入黑屏时,5 个点亮度几乎同步下降
- ❌ 不好的采样点:单个点随角色移动大幅跳动、某点一直是纯黑(边缘黑边)、被 UI / 字幕干扰
调整方法:修改脚本中 BuildSamplePoints() 函数里的比例参数。例如把中央点从 0.50 / 0.40 改成 0.60 / 0.35,数值代表窗口宽高的百分比。
6.2 常见读取异常与解决
-
所有点永远是同一个值 / 明显错误
- 原因:普通
PixelGetColor模式在部分窗口 / 全屏下读取不准确。 - 解决:把脚本中
global PixelMode := ""改为"Alt";仍异常再尝试"Slow"。 - 官方说明:Alt 是替代读取方式,Slow 适配部分全屏应用,但速度约慢 3 倍。
- 原因:普通
-
切出游戏后数值突然异常 正常现象。
PixelGetColor读取的是屏幕实际显示的像素,被其他窗口遮挡的区域无法读取原游戏画面。采集过程中不要用其他窗口覆盖游戏。 -
移动游戏窗口后采样点偏移 脚本只在按 F8 启动时计算一次窗口位置。采集过程中移动过窗口,停止后重新按 F8 启动即可。
-
第二场景本身非常暗 这正是必须采集第二场景数据的原因。不能只用第一场景对比黑屏,要以「更暗的那个正常场景」为基准。 如果第二场景亮度本身接近黑屏,就不能只靠全屏平均亮度,需要结合:固定光源区域、UI 元素、多点亮度差、连续稳定帧、场景特征图像识别等多重判定。
七、后续正式自动化建议:用状态机替代简单阈值
阈值标定完成后,不要写成简单的单次判断:
if (AverageBrightness < Threshold) {
; 判定黑屏结束
}
正确的做法是采用分阶段状态机:
NORMAL(正常场景)
↓ 检测到多点持续变暗
FADING_OUT(淡出中)
↓ 连续保持低亮度
BLACK_SCREEN(黑屏加载)
↓ 检测到多点持续变亮
FADING_IN(淡入中)
↓ 连续稳定在正常亮度范围,波动趋于平稳
SCENE_READY(场景就绪)
特别注意:「第二场景加载完成」不能只用一次亮度高于阈值判定,否则淡入刚开始就可能误判。必须要求画面连续多次高于亮阈值,且亮度波动稳定后,再判定场景就绪。
这份 brightness_log.csv 是每 100ms 一行的逐帧采样日志(每秒 10 条记录),完整记录每个采样点的亮度、统计值和你手动标记的阶段,用 Excel、记事本都能直接打开。
八、表头与字段说明
表格
| 列名 | 含义 |
|---|---|
| Time | 采样时间戳,格式 时:分:秒.毫秒,精确到毫秒 |
| Phase | 当前标记的阶段:NORMAL_1第一场景 / TRANSITION过场 / NORMAL_2第二场景 / UNMARKED未标记 |
| P1 ~ P5 | 5 个采样点各自的感知亮度值(范围 0~255,0 = 纯黑,255 = 纯白) |
| Average | 5 个点的平均亮度(核心判断指标) |
| Minimum | 5 个点里的最低亮度 |
| Maximum | 5 个点里的最高亮度 |
九、完整数据样例(模拟真实场景)
下面是一段典型的「第一场景→过场淡出→黑屏加载→淡入第二场景」完整流程的日志片段:
Time,Phase,P1,P2,P3,P4,P5,Average,Minimum,Maximum
19:20:05.120,NORMAL_1,98.2,112.5,105.7,92.1,108.3,103.36,92.1,112.5
19:20:05.220,NORMAL_1,97.6,113.1,104.9,93.0,107.8,103.28,93.0,113.1
19:20:05.320,NORMAL_1,96.8,111.9,106.2,91.5,109.1,103.10,91.5,111.9
19:20:08.450,TRANSITION,72.3,81.7,76.4,68.9,79.5,75.76,68.9,81.7
19:20:08.550,TRANSITION,41.5,48.2,45.1,39.7,46.8,44.26,39.7,48.2
19:20:08.650,TRANSITION,15.2,18.6,17.1,14.3,17.9,16.62,14.3,18.6
19:20:08.750,TRANSITION,3.7,4.2,3.9,3.5,4.0,3.86,3.5,4.2
19:20:09.100,TRANSITION,2.1,2.5,2.3,2.0,2.4,2.26,2.0,2.5
19:20:11.300,TRANSITION,12.8,15.3,14.1,11.9,14.7,13.76,11.9,15.3
19:20:11.400,TRANSITION,35.6,42.1,38.7,33.2,40.5,38.02,33.2,42.1
19:20:11.500,TRANSITION,68.4,77.9,72.6,65.1,75.3,71.86,65.1,77.9
19:20:12.200,NORMAL_2,82.1,95.3,88.7,78.5,92.4,87.40,78.5,95.3
19:20:12.300,NORMAL_2,81.5,94.7,89.2,79.1,91.8,87.26,79.1,94.7
19:20:12.400,NORMAL_2,82.7,96.1,88.5,78.8,93.0,87.82,78.8,96.1
可以直观看到三段亮度变化:
- NORMAL_1 第一场景:平均亮度稳定在 103 左右,只有视角移动带来的小幅波动
- TRANSITION 过场:平均亮度从 75 → 44 → 16 → 2(完全黑屏)→ 加载完成后逐步回升到 71
- NORMAL_2 第二场景:平均亮度稳定在 87 左右,整体比第一场景稍暗
十、怎么快速解读这份日志
1. 先按阶段拆分看边界
- 筛选
Phase=NORMAL_1和NORMAL_2,取 Average 列的最小值 → 正常画面的暗边界 - 筛选
Phase=TRANSITION,取 Average 列的最大值 → 过场阶段的亮边界
2. 检查采样点质量(同步性)
看 P1~P5 五列数值:
- ✅ 合格:过场时 5 个数值几乎同步下降、同步回升,相互之间差距不大
- ❌ 不合格:某一个点提前变黑 / 一直很低(选到了画面黑边)、某一个点跳动特别大(选到了角色、UI、字幕)
3. 判断能不能用单阈值
- 如果「过场最高亮度」<「正常最低亮度」→ 存在清晰分离带,可以用单阈值做判断
- 如果两者数值有交叉 → 数据重叠,不能只用一个亮度值判断,需要叠加「连续帧、多点同步下降」等条件
4. 进阶用法
把 CSV 导入 Excel,选中 Average 列插入折线图,就能直观看到整个场景切换的亮度变化曲线,非常方便调试阈值、调整采样点。