AutoHotkey v2 零基础快速入门 Part 4 游戏场景明暗阈值标定

1 阅读14分钟

在游戏自动化中,场景切换的黑屏 / 过场识别是非常关键的节点。很多人习惯直接套用网上的固定亮度阈值,但不同游戏、画质设置、分辨率下,明暗数值差异极大,硬抄数值很容易出现误判或漏判。

本文提供一套完整的实测标定方案:通过 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. 游戏设置:设置为窗口化或无边框窗口(方便调试,避免全屏坐标偏移),固定分辨率,关闭会频繁变化的菜单、字幕、浮动提示框。
  2. 游戏内站位:进入第一场景,站在即将进入第二场景的出口附近。
  3. 启动脚本:双击运行脚本,看完使用提示后,切回游戏窗口,保持游戏为前台激活状态。

三、分步采集操作流程

步骤 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,是因为淡出初期、淡入末期画面还没充分变暗 / 变亮。这不是采集失败,而是说明一个重要结论: 完整过场不能只用一个瞬时亮度阈值来定义。

对应的优化思路: 正式自动化时,不能只看「平均亮度低于阈值」,要叠加多重判定:

  1. 多个采样点亮度同步下降 / 上升
  2. 连续若干帧都保持在暗 / 亮区间
  3. 检测到变暗后,等待画面重新稳定再判定场景就绪

六、采样点与读取异常排查

6.1 怎么判断采样点好不好

脚本默认按窗口比例自动选点,无需鼠标。想确认点位可以切回桌面按 F12,查看每个点的坐标、颜色、亮度;也可以直接打开 CSV 查看 P1~P5 五列数据。

  • ✅ 好的采样点:进入黑屏时,5 个点亮度几乎同步下降
  • ❌ 不好的采样点:单个点随角色移动大幅跳动、某点一直是纯黑(边缘黑边)、被 UI / 字幕干扰

调整方法:修改脚本中 BuildSamplePoints() 函数里的比例参数。例如把中央点从 0.50 / 0.40 改成 0.60 / 0.35,数值代表窗口宽高的百分比。

6.2 常见读取异常与解决

  1. 所有点永远是同一个值 / 明显错误

    • 原因:普通 PixelGetColor 模式在部分窗口 / 全屏下读取不准确。
    • 解决:把脚本中 global PixelMode := "" 改为 "Alt";仍异常再尝试 "Slow"。
    • 官方说明:Alt 是替代读取方式,Slow 适配部分全屏应用,但速度约慢 3 倍。
  2. 切出游戏后数值突然异常 正常现象。PixelGetColor 读取的是屏幕实际显示的像素,被其他窗口遮挡的区域无法读取原游戏画面。采集过程中不要用其他窗口覆盖游戏。

  3. 移动游戏窗口后采样点偏移 脚本只在按 F8 启动时计算一次窗口位置。采集过程中移动过窗口,停止后重新按 F8 启动即可。

  4. 第二场景本身非常暗 这正是必须采集第二场景数据的原因。不能只用第一场景对比黑屏,要以「更暗的那个正常场景」为基准。 如果第二场景亮度本身接近黑屏,就不能只靠全屏平均亮度,需要结合:固定光源区域、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 ~ P55 个采样点各自的感知亮度值(范围 0~255,0 = 纯黑,255 = 纯白)
Average5 个点的平均亮度(核心判断指标)
Minimum5 个点里的最低亮度
Maximum5 个点里的最高亮度

九、完整数据样例(模拟真实场景)

下面是一段典型的「第一场景→过场淡出→黑屏加载→淡入第二场景」完整流程的日志片段:

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

可以直观看到三段亮度变化:

  1. NORMAL_1 第一场景:平均亮度稳定在 103 左右,只有视角移动带来的小幅波动
  2. TRANSITION 过场:平均亮度从 75 → 44 → 16 → 2(完全黑屏)→ 加载完成后逐步回升到 71
  3. NORMAL_2 第二场景:平均亮度稳定在 87 左右,整体比第一场景稍暗

十、怎么快速解读这份日志

1. 先按阶段拆分看边界

  • 筛选 Phase=NORMAL_1 和 NORMAL_2,取 Average 列的最小值 → 正常画面的暗边界
  • 筛选 Phase=TRANSITION,取 Average 列的最大值 → 过场阶段的亮边界

2. 检查采样点质量(同步性)

看 P1~P5 五列数值:

  • ✅ 合格:过场时 5 个数值几乎同步下降、同步回升,相互之间差距不大
  • ❌ 不合格:某一个点提前变黑 / 一直很低(选到了画面黑边)、某一个点跳动特别大(选到了角色、UI、字幕)

3. 判断能不能用单阈值

  • 如果「过场最高亮度」<「正常最低亮度」→ 存在清晰分离带,可以用单阈值做判断
  • 如果两者数值有交叉 → 数据重叠,不能只用一个亮度值判断,需要叠加「连续帧、多点同步下降」等条件

4. 进阶用法

把 CSV 导入 Excel,选中 Average 列插入折线图,就能直观看到整个场景切换的亮度变化曲线,非常方便调试阈值、调整采样点。