AutoHotkey v2 零基础快速入门 Part 5 游戏场景明暗阈值采集工具

本工具基于 AutoHotkey v2 开发,用于采集游戏内不同场景的画面亮度数据,自动计算正常画面与黑屏/淡出过场之间的亮度阈值,可用于游戏自动化的场景切换判定。工具支持可视化采样点定位,可直观调整采集位置,输出详细的统计分析与候选阈值。

一、热键功能一览

热键 功能说明
F6 显示 / 隐藏五个采样点标记
F7 重新捕获当前活动窗口,并刷新采样点位置
F8 开始 / 停止亮度采集
F9 标记当前为「第一场景正常画面」
F10 标记当前为「淡出/黑屏过场」
F11 标记当前为「第二场景正常画面」
F12 查看五个采样点的详细信息(坐标、比例、颜色、亮度)
Esc 退出脚本

二、完整脚本代码

bash 复制代码
#Requires AutoHotkey v2.0
#SingleInstance Force
; ============================================================
; 游戏场景明暗阈值采集工具
; ============================================================

; ------------------------------------------------------------
; 坐标模式
; ------------------------------------------------------------
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读取模式

; ============================================================
; 采样点比例配置
; ============================================================
global SamplePointConfigs := [
    {Name: "P1", XRatio: 0.20, YRatio: 0.25, Description: "左上"},
    {Name: "P2", XRatio: 0.80, YRatio: 0.25, Description: "右上"},
    {Name: "P3", XRatio: 0.50, YRatio: 0.40, Description: "中央偏上"},
    {Name: "P4", XRatio: 0.20, YRatio: 0.70, Description: "左下"},
    {Name: "P5", XRatio: 0.80, YRatio: 0.70, Description: "右下"}
]

; ============================================================
; 采样点预览标记配置
; ============================================================
global MarkerGuis := []           ; 采样点GUI对象数组
global AreMarkersVisible := false  ; 标记是否正在显示
global MarkerSize := 46            ; 标记尺寸(像素)
global MarkerColor := "E53935"     ; 标记颜色
global MarkerTransparency := 220   ; 标记透明度(0-255)

; ============================================================
; 各阶段亮度数据缓存
; ============================================================
global Normal1Values := []    ; 第一场景正常画面
global TransitionValues := [] ; 黑屏、淡出、转场画面
global Normal2Values := []    ; 第二场景正常画面

; ============================================================
; 脚本退出事件
; ============================================================
OnExit CleanUpBeforeExit

; ============================================================
; 启动提示
; ============================================================
MsgBox(
    "游戏明暗阈值采集工具`n`n"
    . "推荐使用方法:`n"
    . "1. 先切换到游戏窗口`n"
    . "2. 按 F7 捕获游戏窗口并显示采样点`n"
    . "3. 观察 P1~P5 是否位于合适位置`n"
    . "4. 按 F6 隐藏或再次显示采样点`n"
    . "5. 按 F8 开始正式采集`n"
    . "6. 第一场景正常移动时按 F9`n"
    . "7. 准备进入场景切换时按 F10`n"
    . "8. 第二场景完全显示后按 F11`n"
    . "9. 再按 F8 停止并查看结果`n`n"
    . "正式采样开始时,采样点标记会自动隐藏。`n"
    . "游戏不显示鼠标指针也不影响本脚本。",
    "游戏明暗阈值采集工具"
)

; ============================================================
; 热键定义
; ============================================================
F6:: ToggleSamplePointMarkers()
F7:: RefreshSamplePointMarkers()
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, SampleInterval

    HideSamplePointMarkers() ; 正式采样前隐藏标记,避免污染结果

    ; 窗口不存在则捕获当前活动窗口
    if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
        GameHwnd := WinExist("A")
    }
    if !GameHwnd {
        MsgBox "没有找到当前活动窗口。"
        return
    }

    ; 清空历史数据
    Normal1Values := []
    TransitionValues := []
    Normal2Values := []

    if !BuildSamplePoints()
        return

    ; 重建日志文件
    if FileExist(LogFile) {
        try FileDelete LogFile
        catch as ErrorObject {
            MsgBox(
                "无法删除旧日志文件。`n`n"
                . "日志文件可能正在被Excel或其他程序打开。`n`n"
                . "文件位置:`n"
                . LogFile
                . "`n`n错误信息:`n"
                . ErrorObject.Message,
                "无法创建日志"
            )
            return
        }
    }
    try {
        FileAppend(
            "Time,Phase,"
            . "P1,P2,P3,P4,P5,"
            . "Average,Minimum,Maximum`n",
            LogFile,
            "UTF-8"
        )
    } catch {
        MsgBox("无法创建日志文件。`n`n文件位置:`n" LogFile, "无法创建日志")
        return
    }

    CurrentPhase := "UNMARKED"
    IsSampling := true
    SetTimer CollectSample, SampleInterval

    ToolTip(
        "采集已经开始`n"
        . "采样点标记已经自动隐藏`n`n"
        . "请按 F9 标记第一场景",
        20, 20
    )
}

; ============================================================
; 停止采集
; ============================================================
StopSampling() {
    global IsSampling
    if !IsSampling
        return
    IsSampling := false
    SetTimer CollectSample, 0
    ToolTip()
    ShowSummary()
}

; ============================================================
; 生成采样点坐标
; ============================================================
BuildSamplePoints() {
    global GameHwnd, SamplePoints, SamplePointConfigs
    if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
        MsgBox "游戏窗口不存在,请先切换到游戏并按 F7。"
        return false
    }
    try WinGetClientPos(&WindowX, &WindowY, &WindowW, &WindowH, "ahk_id " GameHwnd)
    catch as ErrorObject {
        MsgBox("无法获取游戏窗口客户区。`n`n错误信息:`n" ErrorObject.Message)
        return false
    }
    if WindowW <= 0 || WindowH <= 0 {
        MsgBox("游戏窗口尺寸无效。`n`n宽度:" WindowW "`n高度:" WindowH)
        return false
    }

    SamplePoints := []
    for Config in SamplePointConfigs {
        PointX := Round(WindowX + WindowW * Config.XRatio)
        PointY := Round(WindowY + WindowH * Config.YRatio)
        SamplePoints.Push({
            Name: Config.Name,
            X: PointX,
            Y: PointY,
            XRatio: Config.XRatio,
            YRatio: Config.YRatio,
            Description: Config.Description
        })
    }
    return true
}

; ============================================================
; 设置采集阶段
; ============================================================
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 := []
    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)
    try {
        FileAppend(
            CurrentTime ","
            . CurrentPhase ","
            . BrightnessValues[1] ","
            . BrightnessValues[2] ","
            . BrightnessValues[3] ","
            . BrightnessValues[4] ","
            . BrightnessValues[5] ","
            . AverageBrightness ","
            . MinimumBrightness ","
            . MaximumBrightness "`n",
            LogFile,
            "UTF-8"
        )
    } catch {
        ; 单次写入失败不终止脚本
    }

    ; 左上角实时显示
    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
    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
}

; ============================================================
; 显示/隐藏采样点切换
; ============================================================
ToggleSamplePointMarkers() {
    global AreMarkersVisible
    if AreMarkersVisible
        HideSamplePointMarkers()
    else
        ShowSamplePointMarkers()
}

; ============================================================
; 重新捕获窗口并刷新采样点
; ============================================================
RefreshSamplePointMarkers() {
    global GameHwnd, IsSampling
    if IsSampling {
        ToolTip("正在正式采样,不能显示位置标记。`n请先按 F8 停止采样。", 20, 20)
        SetTimer HideToolTip, -2000
        return
    }
    GameHwnd := WinExist("A")
    if !GameHwnd {
        MsgBox "没有找到当前活动窗口。"
        return
    }
    if !BuildSamplePoints()
        return
    ShowSamplePointMarkers()
}

; ============================================================
; 显示采样点标记
; ============================================================
ShowSamplePointMarkers() {
    global GameHwnd, SamplePoints, MarkerGuis, AreMarkersVisible
    global IsSampling, MarkerSize, MarkerColor, MarkerTransparency

    if IsSampling {
        ToolTip("正在正式采样,位置标记已被禁止显示。", 20, 20)
        SetTimer HideToolTip, -1500
        return
    }
    if !GameHwnd || !WinExist("ahk_id " GameHwnd)
        GameHwnd := WinExist("A")
    if !GameHwnd {
        MsgBox "没有找到游戏窗口。"
        return
    }
    if !BuildSamplePoints()
        return

    DestroySamplePointMarkers()

    for Point in SamplePoints {
        MarkerGui := Gui("+AlwaysOnTop -Caption +ToolWindow +E0x20")
        MarkerGui.BackColor := MarkerColor
        MarkerGui.SetFont("s10 bold cWhite", "Segoe UI")
        MarkerGui.AddText(
            "Center w" MarkerSize " h" MarkerSize " +0x200",
            Point.Name
        )
        MarkerX := Round(Point.X - MarkerSize / 2)
        MarkerY := Round(Point.Y - MarkerSize / 2)
        MarkerGui.Show("NA x" MarkerX " y" MarkerY " w" MarkerSize " h" MarkerSize)
        try WinSetTransparent(MarkerTransparency, "ahk_id " MarkerGui.Hwnd)
        MarkerGuis.Push(MarkerGui)
    }
    AreMarkersVisible := true
    ToolTip(
        "正在显示采样点预览`n"
        . "红色方块中心就是实际采样坐标`n"
        . "按 F6 隐藏标记`n"
        . "按 F7 重新捕获当前窗口",
        20, 20
    )
    SetTimer HideToolTip, -3000
}

; ============================================================
; 隐藏采样点标记
; ============================================================
HideSamplePointMarkers() {
    global MarkerGuis, AreMarkersVisible
    for MarkerGui in MarkerGuis {
        try MarkerGui.Hide()
    }
    AreMarkersVisible := false
}

; ============================================================
; 销毁所有采样点标记
; ============================================================
DestroySamplePointMarkers() {
    global MarkerGuis, AreMarkersVisible
    for MarkerGui in MarkerGuis {
        try MarkerGui.Destroy()
    }
    MarkerGuis := []
    AreMarkersVisible := false
}

; ============================================================
; 显示当前采样点详情
; ============================================================
ShowCurrentSample() {
    global SamplePoints, PixelMode, GameHwnd, IsSampling

    if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
        MsgBox("没有有效的游戏窗口。`n`n请切换到游戏窗口并按 F7。", "没有游戏窗口")
        return
    }
    if !IsSampling {
        if !BuildSamplePoints()
            return
    }
    if SamplePoints.Length = 0 {
        MsgBox "尚未建立采样点,请先按 F7。"
        return
    }

    Message := ""
    for Point in SamplePoints {
        try {
            Color := PixelGetColor(Point.X, Point.Y, PixelMode)
            Brightness := ColorToBrightness(Color)
            Message .= (
                Point.Name ":" Point.Description "`n"
                . "屏幕坐标:(" Point.X ", " Point.Y ")`n"
                . "相对位置:" Round(Point.XRatio * 100, 1) "%," Round(Point.YRatio * 100, 1) "%`n"
                . "颜色:0x" Format("{:06X}", Color) "`n"
                . "亮度:" Brightness "`n`n"
            )
        } catch as ErrorObject {
            Message .= Point.Name " 读取失败`n错误:" ErrorObject.Message "`n`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 "没有记录黑屏过场数据,无法计算。`n请在淡出或黑屏期间按 F10。"
    }
    if Normal1Values.Length = 0 || Normal2Values.Length = 0 {
        return "两个正常场景的数据不完整,无法计算。`n请分别在两个正常场景中按 F9 和 F11。"
    }

    Normal1Minimum := ArrayMinimum(Normal1Values)
    Normal2Minimum := ArrayMinimum(Normal2Values)
    NormalMinimum := Min(Normal1Minimum, Normal2Minimum)
    TransitionMaximum := ArrayMaximum(TransitionValues)

    ; 可分离区间
    if TransitionMaximum < NormalMinimum {
        CandidateThreshold := Round((TransitionMaximum + NormalMinimum) / 2, 2)
        SeparationGap := Round(NormalMinimum - TransitionMaximum, 2)
        return (
            "数据存在可分离区间。`n"
            . "黑屏最高值:" TransitionMaximum
            . "`n正常最低值:" NormalMinimum
            . "`n分离间距:" SeparationGap
            . "`n候选阈值:" CandidateThreshold
            . "`n`n平均亮度低于该值时,可初步判定画面偏暗。`n"
            . "平均亮度高于该值时,可初步判定画面偏亮。`n`n"
            . "正式自动化时仍建议加入:`n"
            . "1. 多个采样点同步变化`n"
            . "2. 连续若干帧低于阈值`n"
            . "3. 画面重新稳定后再判定场景就绪"
        )
    }

    ; 数据重叠
    OverlapAmount := Round(TransitionMaximum - NormalMinimum, 2)
    return (
        "正常画面和过场数据发生重叠。`n"
        . "黑屏最高值:" TransitionMaximum
        . "`n正常最低值:" NormalMinimum
        . "`n重叠量:" OverlapAmount
        . "`n`n暂时不建议只使用一个固定阈值。`n"
        . "建议尝试以下改进:`n"
        . "1. 调整采样点位置`n"
        . "2. 避开角色、文字、火焰和固定UI`n"
        . "3. 增加连续帧判断`n"
        . "4. 增加多个点同步变暗判断`n"
        . "5. 分别设置进入暗场和离开暗场阈值"
    )
}

; ============================================================
; 隐藏提示
; ============================================================
HideToolTip() {
    ToolTip()
}

; ============================================================
; 退出前清理
; ============================================================
CleanUpBeforeExit(ExitReason, ExitCode) {
    SetTimer CollectSample, 0
    ToolTip()
    DestroySamplePointMarkers()
}

三、标准使用流程

  1. 运行脚本 双击 .ahk 文件启动,弹出功能说明窗口,确认后关闭即可。

  2. 捕获游戏窗口 切换到目标游戏窗口,按下 F7,屏幕上会显示 P1~P5 五个红色标记方块,方块中心即为实际采样坐标。

  3. 调整采样位置 观察红色标记是否落在目标采集区域:

    • 位置合适:按 F6 可隐藏标记,避免干扰画面
    • 位置不合适:参考下文「采样点调整方法」修改参数后,重新按 F7 刷新
  4. 启动正式采集 按下 F8 启动采集,采样点标记会自动隐藏,避免污染采集结果。左上角会实时显示当前阶段、平均亮度、最低/最高亮度。

  5. 分阶段标记场景

    • 第一场景画面正常稳定时:按 F9
    • 场景切换、淡出/黑屏过程中:按 F10
    • 第二场景完全显示、画面稳定后:按 F11
  6. 停止并查看结果 再次按下 F8 停止采集,自动弹出结果窗口,包含:

    • 三个阶段的亮度范围(样本数、最低、最高、平均)
    • 阈值分析与候选阈值建议
    • CSV 日志文件路径
  7. 查看采样点详情(可选) 按下 F12 可查看每个采样点的屏幕坐标、相对比例、颜色值、亮度值。


四、采样点位置调整方法

工具采用窗口相对比例 定位采样点,调整时只需修改脚本顶部的 SamplePointConfigs 配置数组,无需改动核心代码。

参数说明

  • XRatio:水平方向比例,0.00 = 窗口最左侧,1.00 = 窗口最右侧
  • YRatio:垂直方向比例,0.00 = 窗口最顶部,1.00 = 窗口最底部
  • Description:采样点备注,仅用于展示

调整示例

  • 中间点 P3 向下移动:将 YRatio: 0.40 改为 YRatio: 0.55
  • 左上点 P1 向中间靠拢:将 XRatio: 0.20 改为 XRatio: 0.30

修改完成后保存脚本,按 F7 即可刷新标记位置。


五、核心功能说明

1. 亮度计算

采用人眼感知加权亮度公式:

亮度 = 红×0.2126 + 绿×0.7152 + 蓝×0.0722

亮度范围 0~255,更符合真实视觉感受。

2. 阈值判定逻辑

采集完成后自动分析两组数据分布:

  • 可分离区间:黑屏最高亮度 < 正常画面最低亮度

    • 输出候选阈值(取两者中间值)
    • 亮度低于阈值判定为偏暗,高于判定为偏亮
    • 同时给出自动化优化建议
  • 数据重叠:黑屏最高亮度 ≥ 正常画面最低亮度

    • 提示重叠量,不建议使用单一固定阈值
    • 给出优化方向:调整采样点、避开UI/角色、增加连续帧判断、双阈值方案等

3. 数据日志

所有采样数据自动保存为 brightness_log.csv(脚本同目录),包含时间戳、阶段、五个点亮度值、平均/最低/最高亮度,可用于后续二次分析。

4. 防污染设计

正式采集开始时,采样点标记会自动隐藏,避免红色标记被像素读取到,保证采集数据准确。

💡 建议每个阶段保持采集 3~5 秒,足够的样本量会让阈值计算更准确;尽量避开角色、动态UI、火焰等亮度频繁变化的区域。

相关推荐
GoGeekBaird1 小时前
我做了一个给 Agent 用的电商开源项目:开店、上架、改版,跟 Agent 说一声就行
后端·github·agent
云和数据.ChenGuang1 小时前
Spring AI 对接 LangGraph:构建企业级智能体工作流实战
java·人工智能·spring boot·后端·spring
woniu_maggie1 小时前
SAP 主数据 MDM 与MDG分析
后端
1 小时前
正则少一个 re.MULTILINE,我的安全闸门全程空转——而我以为修好了
后端·代码规范
柠檬味拥抱1 小时前
脑肿瘤检测数据集 | 4400张YOLO医学影像数据集
后端
柠檬味拥抱2 小时前
汽车损伤检测数据集 | 11700张YOLO车辆定损数据集
后端
柠檬味拥抱2 小时前
基于 YOLO11 的规模化禽舍鸡只行为视觉监测平台:从模型到 Streamlit 全流程实战
后端
柠檬味拥抱2 小时前
火灾智能检测预警系统:从 YOLO11n 训练到 Streamlit 全链路实战【实战源码】
后端
言午说数据2 小时前
Paimon把LSM搬进湖里的得与失
大数据·后端