Files
better-genshin-impact/BetterGenshinImpact/GameTask/AutoPick/AutoPickTrigger.cs

575 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using BetterGenshinImpact.Core.Config;
using BetterGenshinImpact.Core.Recognition;
using BetterGenshinImpact.Core.Recognition.OCR;
using BetterGenshinImpact.Core.Recognition.ONNX.SVTR;
using BetterGenshinImpact.Core.Script.Dependence.Model.TimerConfig;
using BetterGenshinImpact.Core.Simulator;
using BetterGenshinImpact.GameTask.AutoPick.Assets;
using BetterGenshinImpact.Helpers;
using BetterGenshinImpact.Service;
using BetterGenshinImpact.View.Windows;
using Microsoft.Extensions.Logging;
using OpenCvSharp;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using BetterGenshinImpact.GameTask.Model.Area;
namespace BetterGenshinImpact.GameTask.AutoPick;
public partial class AutoPickTrigger : ITaskTrigger
{
private readonly ILogger<AutoPickTrigger> _logger = App.GetLogger<AutoPickTrigger>();
private readonly ITextInference _pickTextInference = TextInferenceFactory.Pick;
public string Name => "自动拾取";
public bool IsEnabled { get; set; }
public int Priority => 30;
public bool IsExclusive => false;
private readonly AutoPickAssets _autoPickAssets;
/// <summary>
/// 拾取黑名单
/// </summary>
private HashSet<string> _blackList = [];
/// <summary>
/// 拾取黑名单(模糊匹配)
/// </summary>
private List<string> _fuzzyBlackList = [];
/// <summary>
/// 拾取白名单
/// </summary>
private HashSet<string> _whiteList = [];
private RecognitionObject _pickRo;
// 外部配置
private AutoPickExternalConfig? _externalConfig;
public AutoPickTrigger()
{
_autoPickAssets = AutoPickAssets.Instance;
_pickRo = _autoPickAssets.PickRo;
}
public AutoPickTrigger(AutoPickExternalConfig? config) : this()
{
_externalConfig = config;
}
public void Init()
{
var config = TaskContext.Instance().Config.AutoPickConfig;
IsEnabled = config.Enabled;
if (config.BlackListEnabled)
{
_blackList = ReadJson(@"Assets\Config\Pick\default_pick_black_lists.json");
var userBlackList = ReadText(@"User\pick_black_lists.txt");
if (userBlackList.Count > 0)
{
_blackList.UnionWith(userBlackList);
}
_fuzzyBlackList = ReadTextList(@"User\pick_fuzzy_black_lists.txt");
}
if (config.WhiteListEnabled)
{
_whiteList = ReadText(@"User\pick_white_lists.txt");
}
}
private HashSet<string> ReadJson(string jsonFilePath)
{
try
{
var json = Global.ReadAllTextIfExist(jsonFilePath);
if (!string.IsNullOrEmpty(json))
{
return JsonSerializer.Deserialize<HashSet<string>>(json, ConfigService.JsonOptions) ?? [];
}
}
catch (Exception e)
{
_logger.LogError(e, "读取拾取黑/白名单失败");
ThemedMessageBox.Error("读取拾取黑/白名单失败,请确认修改后的拾取黑/白名单内容格式是否正确!");
}
return [];
}
private HashSet<string> ReadText(string textFilePath)
{
try
{
var txt = Global.ReadAllTextIfExist(textFilePath);
if (!string.IsNullOrEmpty(txt))
{
// 明确指定使用 char[] 重载版本
return new HashSet<string>(txt.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries));
}
}
catch (Exception e)
{
_logger.LogError(e, "读取拾取黑/白名单失败");
ThemedMessageBox.Error("读取拾取黑/白名单失败,请确认修改后的拾取黑/白名单内容格式是否正确!");
}
return [];
}
private List<string> ReadTextList(string textFilePath)
{
try
{
var txt = Global.ReadAllTextIfExist(textFilePath);
if (!string.IsNullOrEmpty(txt))
{
// 明确指定使用 char[] 重载版本
return [..txt.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)];
}
}
catch (Exception e)
{
_logger.LogError(e, "读取拾取黑/白名单失败");
ThemedMessageBox.Error("读取拾取黑/白名单失败,请确认修改后的拾取黑/白名单内容格式是否正确!");
}
return [];
}
/// <summary>
/// 用于日志只输出一次
/// </summary>
private string _lastText = string.Empty;
/// <summary>
/// 用于日志只输出一次
/// </summary>
private int _prevClickFrameIndex = -1;
//private int _fastModePickCount = 0;
public void OnCapture(CaptureContent content)
{
while (RunnerContext.Instance.AutoPickTriggerStopCount > 0)
{
Thread.Sleep(1000);
}
var speedTimer = new SpeedTimer();
using var foundRectArea = content.CaptureRectArea.Find(_pickRo);
if (foundRectArea.IsEmpty())
{
// 没有识别到F键先判断是否有滚轮图标信息
if (HasScrollIcon(content.CaptureRectArea))
{
// 滚轮下
Simulation.SendInput.Mouse.VerticalScroll(2);
Thread.Sleep(50);
}
return;
}
speedTimer.Record($"识别到拾取键");
if (_externalConfig is { ForceInteraction: true })
{
LogPick(content, "直接拾取");
Simulation.SendInput.Keyboard.KeyPress(AutoPickAssets.Instance.PickVk);
return;
}
var scale = TaskContext.Instance().SystemInfo.AssetScale;
var config = TaskContext.Instance().Config.AutoPickConfig;
// 存在 L 键位是千星奇遇,无需拾取
using var lKeyRa = content.CaptureRectArea.Find(_autoPickAssets.LRo);
if (lKeyRa.IsExist())
{
return;
}
// 识别到拾取键,开始识别物品图标
var isExcludeIcon = false;
_autoPickAssets.ChatIconRo.RegionOfInterest = new Rect(
foundRectArea.X + (int)(config.ItemIconLeftOffset * scale), foundRectArea.Y,
(int)((config.ItemTextLeftOffset - config.ItemIconLeftOffset) * scale), foundRectArea.Height);
using var chatIconRa = content.CaptureRectArea.Find(_autoPickAssets.ChatIconRo);
speedTimer.Record("识别聊天图标");
if (!chatIconRa.IsEmpty())
{
// 物品图标是聊天气泡一般是NPC对话文字不在白名单不拾取
isExcludeIcon = true;
}
else
{
_autoPickAssets.SettingsIconRo.RegionOfInterest = _autoPickAssets.ChatIconRo.RegionOfInterest;
using var settingsIconRa = content.CaptureRectArea.Find(_autoPickAssets.SettingsIconRo);
speedTimer.Record("识别设置图标");
if (!settingsIconRa.IsEmpty())
{
// 物品图标是设置图标,一般是解谜、活动、电梯等
isExcludeIcon = true;
}
}
if (!config.WhiteListEnabled && isExcludeIcon)
{
// 默认不拾取且没有白名单直接放弃OCR
return;
}
if (!config.WhiteListEnabled && !config.BlackListEnabled && !isExcludeIcon)
{
// 没有黑白名单直接拾取
Simulation.SendInput.Keyboard.KeyPress(AutoPickAssets.Instance.PickVk);
LogPick(content, "黑名单未启用,直接拾取");
}
//if (config.FastModeEnabled && !isExcludeIcon)
//{
// _fastModePickCount++;
// if (_fastModePickCount > 2)
// {
// _fastModePickCount = 0;
// LogPick(content, "急速拾取");
// Simulation.SendInput.Keyboard.KeyPress(VirtualKeyCode.VK_F);
// }
// return;
//}
// 这类文字识别比较特殊,都是针对某个场景的文字识别,所以暂时未抽象到识别对象中
// 计算出文字区域
var textRect = new Rect(foundRectArea.X + (int)(config.ItemTextLeftOffset * scale), foundRectArea.Y,
(int)((config.ItemTextRightOffset - config.ItemTextLeftOffset) * scale), foundRectArea.Height);
if (textRect.X + textRect.Width > content.CaptureRectArea.CacheGreyMat.Width
|| textRect.Y + textRect.Height > content.CaptureRectArea.CacheGreyMat.Height)
{
Debug.WriteLine("AutoPickTrigger: 文字区域 out of range");
return;
}
using var gradMat = new Mat(content.CaptureRectArea.CacheGreyMat,
new Rect(textRect.X, textRect.Y, textRect.Width, Math.Min(textRect.Height, 3)));
var avgGrad = gradMat.Sobel(MatType.CV_32F, 1, 0).Mean().Val0;
if (avgGrad < -3)
{
Debug.WriteLine($"AutoPickTrigger: 已在拾取中,跳过本次拾取 {avgGrad}");
return;
}
string text;
if (config.OcrEngine == nameof(PickOcrEngineEnum.Yap))
{
var textMat = new Mat(content.CaptureRectArea.CacheGreyMat, textRect);
text = _pickTextInference.Inference(textMat);
}
else
{
using var textMat = new Mat(content.CaptureRectArea.SrcMat, textRect);
var boundingRect = TextRectExtractor.GetTextBoundingRect(textMat);
// var boundingRect = new Rect(); // 不使用自己写的文字区域提取
// 如果找到有效区域
if (boundingRect.X < 20 && boundingRect.Width > 5 && boundingRect.Height > 5)
{
// 截取只包含文字的区域
using var textOnlyMat = new Mat(textMat, new Rect(0, 0,
boundingRect.Right + 5 < textMat.Width ? boundingRect.Right + 5 : textMat.Width, textMat.Height));
text = OcrFactory.Paddle.OcrWithoutDetector(textOnlyMat);
// if (RuntimeHelper.IsDebug)
// {
// // 如果不等于正确文字,则保存图片
// if (text != "烹饪")
// {
// var path = Global.Absolute("log/pick");
// Directory.CreateDirectory(path);
// var str = $"{DateTime.Now:yyyyMMddHHmmssfff}";
// // textMat.SaveImage(Path.Combine(path, $"pick_ocr_ori_{str}.png"));
// // 画上 boundingRect
// Cv2.Rectangle(textMat, boundingRect, new Scalar(0, 0, 255), 1);
// textMat.SaveImage(Path.Combine(path, $"pick_ocr_rect_{str}.png"));
// bin.SaveImage(Path.Combine(path, $"bin_{str}.png"));
// }
// }
}
else
{
Debug.WriteLine("-- 无法识别到有效文字区域尝试直接OCR DET");
text = OcrFactory.Paddle.Ocr(textMat);
}
}
speedTimer.Record("文字识别");
if (!string.IsNullOrEmpty(text))
{
// 处理OCR识别结果清理无效字符并确保引号配对
text = ProcessOcrText(text);
if (DoNotPick(text))
{
return;
}
// 单个字符不拾取
if (text.Length <= 1)
{
return;
}
if (config.WhiteListEnabled && _whiteList.Contains(text))
{
LogPick(content, text);
Simulation.SendInput.Keyboard.KeyPress(AutoPickAssets.Instance.PickVk);
return;
}
speedTimer.Record("白名单判断");
if (isExcludeIcon)
{
//Debug.WriteLine("AutoPickTrigger: 物品图标是聊天气泡一般是NPC对话不拾取");
return;
}
if (config.BlackListEnabled)
{
if (_blackList.Contains(text))
{
return;
}
if (_fuzzyBlackList.Count > 0)
{
if (_fuzzyBlackList.Any(item => text.Contains(item)))
{
return;
}
}
}
speedTimer.Record("黑名单判断");
LogPick(content, text);
Simulation.SendInput.Keyboard.KeyPress(AutoPickAssets.Instance.PickVk);
}
speedTimer.DebugPrint();
}
private bool DoNotPick(string text)
{
// 唯一一个动态拾取项,特殊处理,不拾取
if (text.Contains("长时间"))
{
return true;
}
// 纳塔部落中文名特殊处理,不拾取
if (text.Contains("我在") && (text.Contains("声望") || text.Contains("回声") || text.Contains("悬木人") ||
text.Contains("流泉")))
{
return true;
}
// 挪德卡莱聚所中文名特殊处理,不拾取
if (text.Contains("聚所"))
{
return true;
}
if (text.Contains("霜月") && text.Contains("坊"))
{
return true;
}
if (text.Contains("叮铃") || text.Contains("眶螂") || (text.Contains("蛋卷") && text.Contains("坊")))
{
return true;
}
if (text.Contains("西风成垒") || text.Contains("望崖营壁") || text.Contains("魔女的花园"))
{
return true;
}
return false;
}
public static Rect GetWhiteTextBoundingRect(Mat textMat)
{
// 预处理提取纯白色文字
var processedMat = new Mat();
// 提取白色文字 (255,255,255)
Cv2.InRange(textMat, new Scalar(254, 254, 254), new Scalar(255, 255, 255), processedMat);
// 形态学操作,先腐蚀后膨胀,去除噪点并保持文字完整
var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(2, 2));
Cv2.MorphologyEx(processedMat, processedMat, MorphTypes.Open, kernel, iterations: 1);
Cv2.Dilate(processedMat, processedMat, kernel, iterations: 1);
// 寻找非零区域,即文字区域
Rect boundingRect = Cv2.BoundingRect(processedMat);
return boundingRect;
}
private bool HasScrollIcon(ImageRegion captureRectArea)
{
// 固定区域颜色判断
// (1062,537) (255,233,44) 黄色
// (1062,524) (255,255,255) 白色
// (1062,583) (255,255,255) 白色
var mat = captureRectArea.SrcMat;
var color1 = mat.At<Vec3b>(537, 1062);
var color2 = mat.At<Vec3b>(524, 1062);
var color3 = mat.At<Vec3b>(554, 1062);
// BGR 的格式
if (color1.Item2 == 255 && color1.Item1 == 233 && color1.Item0 == 44
&& color2.Item2 == 255 && color2.Item1 == 255 && color2.Item0 == 255
&& color3.Item2 == 255 && color3.Item1 == 255 && color3.Item0 == 255)
{
return true;
}
return false;
}
/// <summary>
/// 相同文字前后3帧内只输出一次
/// </summary>
/// <param name="content"></param>
/// <param name="text"></param>
private void LogPick(CaptureContent content, string text)
{
if (_lastText != text || (_lastText == text && Math.Abs(content.FrameIndex - _prevClickFrameIndex) >= 5))
{
_logger.LogInformation("交互或拾取:{Text}", text);
}
_lastText = text;
_prevClickFrameIndex = content.FrameIndex;
}
/// <summary>
/// 高性能处理OCR识别的文字结果
/// 1. 替换【、[ 为「,替换】、] 为」
/// 2. 清理左边非「字符和中文的字符
/// 3. 清理右边非」字符和中文的字符
/// 4. 确保引号配对:有「必有」,有」必有「
/// </summary>
/// <param name="text">OCR识别的原始文字</param>
/// <returns>处理后的文字</returns>
private static string ProcessOcrText(string text)
{
if (string.IsNullOrEmpty(text))
return text;
// 0. 首先替换相似的括号字符并删除换行符、空格使用Span<char>进行原地替换以获得最佳性能
Span<char> chars = stackalloc char[text.Length];
text.AsSpan().CopyTo(chars);
int writeIndex = 0;
bool hasChanges = false;
for (int i = 0; i < chars.Length; i++)
{
char c = chars[i];
// 跳过换行符、回车符、空格、制表符等空白字符
if (char.IsWhiteSpace(c))
{
hasChanges = true;
continue;
}
// 替换括号字符
if (c == '【' || c == '[')
{
chars[writeIndex++] = '「';
hasChanges = true;
}
else if (c == '】' || c == ']')
{
chars[writeIndex++] = '」';
hasChanges = true;
}
else
{
chars[writeIndex++] = c;
}
}
// 如果有变化使用处理后的字符否则使用原字符串的Span
ReadOnlySpan<char> span = hasChanges ? chars.Slice(0, writeIndex) : text.AsSpan();
int start = 0;
int end = span.Length - 1;
// 1. 从左边开始,删除非「字符和中文的字符
while (start <= end)
{
char c = span[start];
if (c == '「' || (c >= 0x4E00 && c <= 0x9FFF)) // 「字符或中文字符
break;
start++;
}
// 2. 从右边开始,删除非」字符和中文的字符
while (end >= start)
{
char c = span[end];
if (c == '」' || c == '' || (c >= 0x4E00 && c <= 0x9FFF)) // 」字符或中文字符
break;
end--;
}
// 如果所有字符都被删除了
if (start > end)
return string.Empty;
// 获取清理后的文字
var cleanedSpan = span.Slice(start, end - start + 1);
// 3. 检查并补充引号配对
bool hasLeftQuote = false;
bool hasRightQuote = false;
// 快速扫描是否存在引号
for (int i = 0; i < cleanedSpan.Length; i++)
{
if (cleanedSpan[i] == '「')
hasLeftQuote = true;
else if (cleanedSpan[i] == '」')
hasRightQuote = true;
}
// 根据引号配对规则补充
if (hasLeftQuote && !hasRightQuote)
{
// 有「但没有」,在末尾补充」
Debug.WriteLine("补充缺失的右引号");
return string.Concat(cleanedSpan, "」");
}
else if (hasRightQuote && !hasLeftQuote)
{
// 有」但没有「,在开头补充「
Debug.WriteLine("补充缺失的左引号");
return string.Concat("「", cleanedSpan);
}
return cleanedSpan.ToString();
}
}