mirror of
https://github.com/babalae/better-genshin-impact.git
synced 2026-05-15 09:17:13 +08:00
* chore: add AGENTS.md to .gitignore * feat(config): 新增 AllowDuplicateChar OCR配置项 * refactor(ocr): Rec 暴露protected成员、提取RunInference、支持AllowDuplicateChar * feat(ocr): 打通 AllowDuplicateChar 参数链 PaddleOcrService → Rec * feat(ocr): OcrUtils 新增 CreateLabelDict/CreateWeights 工具方法 * feat(helpers): 新增 LruCache 缓存工具类 * feat(ocr): 新增 RecMatch DP模糊匹配识别器 * test(helpers): 新增 LruCache 单元测试 * test(ocr): 新增 RecMatch.GetTarget / CreateLabelDict 单元测试 * fix(ocr): 修复 RecMatch 中权重矩阵乘法的使用方式 * refactor(ocr): 合并 RecMatch 到 Rec,提取可测试静态方法,补充单元测试 将 RecMatch 子类合并到 Rec 中,消除继承关系和重复的批处理逻辑(提取 RunBatch<T>)。 将 GetTarget 核心逻辑和 GetMaxScoreDP 提取为 OcrUtils 静态方法以便独立测试。 重命名测试文件并新增 16 个单元测试覆盖 MapStringToLabelIndices、GetMaxScoreDP、CreateWeights。 * feat(ocr): 将 Rec.RunMatch 暴露给 JS 引擎和内部 C# 代码 新增 IOcrMatchService 接口,提供基于 DP 模糊匹配的 OcrMatch/OcrMatchDirect 方法, 返回 0~1 置信度分数。PaddleOcrService 实现该接口,OcrFactory.PaddleMatch 保证 非 null 返回(引擎不支持时自动回退到普通 OCR + 编辑距离字符串比较)。 BvPage 新增 OcrMatch/WaitForOcrMatch 供 JS 脚本使用,阈值可通过配置调整。 * feat(ui): 为 OCR 配置添加允许重复字符和模糊匹配阈值的设置项 在通用设置页 OCR 配置区域新增两个控件: - 允许连续重复字符(AllowDuplicateChar)开关 - OCR模糊匹配阈值(OcrMatchDefaultThreshold)输入框 * fix: 修复 PR #2799 代码审查中发现的多项问题 - 修复 Rec.cs 空文本时 score/sb.Length 除零产生 NaN - 修复 BvPage.cs rect==default 时同一对象被双重 Dispose - 移除 Rec.cs Finalizer 避免 GC 线程加锁死锁 - 移除 CacheHelper WeakKey 无效功能,简化为直接 Dictionary 查找 - 添加 weights 数组长度与模型输出维度校验 - 修复 CreateLabelDict 空格标签索引冲突 - 修复 GetMaxScoreDP availableCount=0 除零 - 修复 OcrMatchFallbackService Contains 大小写敏感 - 修复 BvPage.cs DefaultRetryInterval=0 除零 - 添加 OcrMatchDefaultThreshold [0,1] 范围约束 - 提取 PaddleOcrService BGRA→BGR 转换辅助方法 - 使用 Interlocked.CompareExchange 修复 OcrFactory Fallback 线程安全 - 增大 LruCacheTests BuilderTest TTL 裕量避免 CI 不稳定 - 更新 .gitignore 注释 * fix: 修复 OcrMatch 归一化分母导致多区域匹配分数过低的 bug,改进 UI - 修复 GetMaxScoreFlat 中 availableCount 使用非空图像数作为分母, 导致多文字区域场景下匹配分数被过度稀释的问题,改为使用 target.Length - AllowDuplicateChar 设置项添加"需重新加载OCR引擎"的提示 - OCR模糊匹配阈值控件从 TextBox 改为 Slider + 数值显示 - 移除 Det 类中有问题的 finalizer(含锁的析构函数可能导致死锁) - 补充多区域场景的单元测试 * feat(ocr): 添加队伍切换时使用OcrMatch模糊匹配的选项和相关配置 * fix(ui): 更新匹配成功阈值默认值为 0.8 * fix(ocr): 修复队伍切换逻辑中的空值处理和优化代码结构 * refactor: 简化 LruCache,移除弱引用支持和 Builder 模式 - 移除有 TOCTOU bug 的 WeakReference 支持(且无实际使用方) - CacheItem 类改为 ValueTuple 减少堆分配 - 无过期时不再赋值 DateTime.MaxValue,过期检查短路跳过 - 移除仅剩两参数的 LruCacheBuilder,直接使用构造函数 * fix(ocr): 修复 CreateWeights 中空格字符权重写入错误索引的 bug 复用 CreateLabelDict 构建索引映射,确保空格映射到 labels.Count+1, 与 CreateLabelDict 保持一致。添加对应测试用例。 * fix(ocr): 修复 GCHandle.Alloc 失败时 finally 中 Free 掩盖原始异常的问题 * fix(ocr): 添加队伍选择按钮存在性检查,避免 PartySetupFailedException * fix(ocr): 调整 OcrMatchDefaultThreshold 的 TickFrequency 为 0.01 * fix(ocr): 修复区域裁剪逻辑,确保裁剪尺寸不为负值 * fix(ocr): 优化字符置信度提取逻辑,直接按目标字符索引查找置信度 * fix(ocr): 修正变量命名以保持一致性,调整方法名大小写 * fix(ocr): 修改 CreateWeights 方法以使用标签字典和标签计数,优化权重创建逻辑 * fix(ocr): 更新 OCR 置信度阈值设置,确保阈值范围为 0.01 到 0.99,并优化相关逻辑
342 lines
13 KiB
C#
342 lines
13 KiB
C#
using BetterGenshinImpact.Core.Recognition;
|
||
using BetterGenshinImpact.Core.Recognition.OCR;
|
||
using BetterGenshinImpact.Core.Simulator;
|
||
using BetterGenshinImpact.Core.Simulator.Extensions;
|
||
using BetterGenshinImpact.GameTask.Common.BgiVision;
|
||
using BetterGenshinImpact.GameTask.Common.Element.Assets;
|
||
using BetterGenshinImpact.GameTask.Common.Exceptions;
|
||
using BetterGenshinImpact.GameTask.Model.Area;
|
||
using BetterGenshinImpact.View.Drawable;
|
||
using Microsoft.Extensions.Logging;
|
||
using OpenCvSharp;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Text.RegularExpressions;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Vanara.PInvoke;
|
||
using static BetterGenshinImpact.GameTask.Common.TaskControl;
|
||
|
||
namespace BetterGenshinImpact.GameTask.Common.Job;
|
||
|
||
public class SwitchPartyTask
|
||
{
|
||
private readonly double _assetScale = TaskContext.Instance().SystemInfo.AssetScale;
|
||
|
||
public string Name => "切换队伍";
|
||
|
||
private readonly ReturnMainUiTask _returnMainUiTask = new();
|
||
|
||
public async Task<bool> Start(string partyName, CancellationToken ct)
|
||
{
|
||
var useOcrMatch = TaskContext.Instance().Config.OtherConfig.OcrConfig.UseOcrMatchForPartySwitch;
|
||
|
||
Logger.LogInformation("尝试切换至队伍: {Name}", partyName);
|
||
using var ra1 = CaptureToRectArea();
|
||
|
||
// 确保进入队伍配置界面
|
||
bool isInPartyViewUi = false;
|
||
if (!Bv.IsInPartyViewUi(ra1))
|
||
{
|
||
isInPartyViewUi = true;
|
||
await EnsurePartyViewOpen(ra1, ct);
|
||
}
|
||
|
||
await Delay(500, ct);
|
||
|
||
using var ra = CaptureToRectArea();
|
||
var partyViewBtn = ra.Find(ElementAssets.Instance.PartyBtnChooseView);
|
||
|
||
if (!partyViewBtn.IsExist())
|
||
{
|
||
Logger.LogWarning("未找到队伍选择按钮,无法判断当前队伍");
|
||
throw new PartySetupFailedException("未找到队伍选择按钮");
|
||
}
|
||
|
||
// 检查当前队伍是否已是目标
|
||
if (IsCurrentTeamMatch(ra, partyViewBtn, partyName, useOcrMatch))
|
||
{
|
||
if (isInPartyViewUi)
|
||
{
|
||
Simulation.SendInput.Keyboard.KeyPress(User32.VK.VK_ESCAPE);
|
||
await Delay(500, ct);
|
||
await _returnMainUiTask.Start(ct);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 打开队伍选择页面
|
||
var partyDeleteBtn = await OpenPartyChoosePage(partyViewBtn, ct);
|
||
await ScrollToTop(ct);
|
||
|
||
// 逐页查找目标队伍
|
||
Rect regionOfInterest = new(0, (int)(80 * _assetScale), partyDeleteBtn.Right, partyDeleteBtn.Top - (int)(80 * _assetScale));
|
||
var recognitionObject = new RecognitionObject
|
||
{
|
||
RecognitionType = RecognitionTypes.Ocr,
|
||
RegionOfInterest = regionOfInterest,
|
||
DrawOnWindow = true,
|
||
Name = "队伍名称",
|
||
DrawOnWindowPen = System.Drawing.Pens.White
|
||
};
|
||
|
||
try
|
||
{
|
||
for (var i = 0; i < 16; i++) // 6.0版本最多20个队伍
|
||
{
|
||
using var page = CaptureToRectArea();
|
||
var nameList = page.FindMulti(recognitionObject);
|
||
|
||
if (nameList == null || nameList.Count <= 0)
|
||
{
|
||
Logger.LogInformation("管理队伍界面文字识别失败");
|
||
break;
|
||
}
|
||
|
||
// 在当前页查找匹配
|
||
var (match, score) = FindMatchInPage(page, nameList, partyName, useOcrMatch);
|
||
if (match != null)
|
||
{
|
||
page.ClickTo(match.Right + match.Width, match.Bottom);
|
||
await Delay(200, ct);
|
||
if (useOcrMatch)
|
||
Logger.LogInformation("切换队伍成功: {Text}(匹配分数: {Score:F4})", match.Text, score);
|
||
else
|
||
Logger.LogInformation("切换队伍成功: {Text}", match.Text);
|
||
await ConfirmParty(page, ct, isInPartyViewUi);
|
||
RunnerContext.Instance.ClearCombatScenes();
|
||
return true;
|
||
}
|
||
|
||
// 判断是否已遍历所有队伍
|
||
var lowest = nameList
|
||
.Where(r => r.X > 35 * _assetScale && r.X < 100 * _assetScale)
|
||
.OrderBy(r => r.Y)
|
||
.LastOrDefault();
|
||
if (lowest == null)
|
||
{
|
||
Logger.LogInformation("未找到符合坐标范围的队伍名称,跳过翻页判断");
|
||
continue;
|
||
}
|
||
lowest.DrawSelf("底部的队伍");
|
||
|
||
if (lowest.Y < 777 * _assetScale) // 如果最底下是空队伍则不会有队伍名,以此判断是否已遍历完成
|
||
{
|
||
Logger.LogInformation("已抵达最后一个队伍");
|
||
break;
|
||
}
|
||
|
||
// 翻页
|
||
if (i == 0)
|
||
{
|
||
// 首次点一下第一个,防止第五个被点击过
|
||
page.ClickTo(600 * _assetScale, 200 * _assetScale);
|
||
await Task.Delay(300, ct);
|
||
}
|
||
|
||
page.ClickTo(regionOfInterest.X + regionOfInterest.Width / 2, lowest.Bottom);
|
||
await Delay(400, ct);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
VisionContext.Instance().DrawContent.ClearAll();
|
||
}
|
||
|
||
// 未找到
|
||
Logger.LogError("未找到队伍: {Name},返回主界面", partyName);
|
||
Logger.LogInformation(useOcrMatch
|
||
? "如果找不到设定的队伍名,有可能是文字识别效果不佳,请尝试调整 OcrMatch 模糊匹配阈值"
|
||
: "如果找不到设定的队伍名,有可能是文字识别效果不佳,请尝试正则表达式");
|
||
await _returnMainUiTask.Start(ct);
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 确保队伍配置界面已打开。如果不在主界面则先返回主界面,然后打开队伍配置。
|
||
/// </summary>
|
||
private async Task EnsurePartyViewOpen(ImageRegion currentScreen, CancellationToken ct)
|
||
{
|
||
if (!Bv.IsInMainUi(currentScreen))
|
||
{
|
||
await _returnMainUiTask.Start(ct);
|
||
await Delay(200, ct);
|
||
using var raMain = CaptureToRectArea();
|
||
if (!Bv.IsInMainUi(raMain))
|
||
throw new InvalidOperationException("未能返回主界面");
|
||
}
|
||
|
||
const int maxAttempts = 2;
|
||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||
{
|
||
Simulation.SendInput.SimulateAction(GIActions.OpenPartySetupScreen);
|
||
for (int i = 0; i < 7; i++) // 考虑加载时间 2s,共检查 4.2s
|
||
{
|
||
await Delay(600, ct);
|
||
using var raCheck = CaptureToRectArea();
|
||
if (Bv.IsInPartyViewUi(raCheck)) return;
|
||
}
|
||
}
|
||
|
||
throw new PartySetupFailedException("未能打开队伍配置界面");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检查当前队伍名称是否匹配目标
|
||
/// </summary>
|
||
private bool IsCurrentTeamMatch(ImageRegion ra, Region partyViewBtn, string partyName, bool useOcrMatch)
|
||
{
|
||
var roi = new Rect(partyViewBtn.Right, partyViewBtn.Top, (int)(350 * _assetScale), partyViewBtn.Height);
|
||
|
||
if (useOcrMatch)
|
||
{
|
||
var matchService = OcrFactory.PaddleMatch;
|
||
var threshold = TaskContext.Instance().Config.OtherConfig.OcrConfig.OcrMatchDefaultThreshold;
|
||
using var region = ra.DeriveCrop(roi);
|
||
var score = matchService.OcrMatch(region.SrcMat, partyName);
|
||
Logger.LogInformation("切换队伍,当前队伍 OcrMatch 分数: {Score:F4},判断阈值: {Threshold}", score, threshold);
|
||
if (score >= threshold)
|
||
{
|
||
Logger.LogInformation("当前队伍即为目标队伍(匹配分数: {Score:F4}),无需切换", score);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
var text = CleanOcrText(ra.Find(new RecognitionObject
|
||
{
|
||
RecognitionType = RecognitionTypes.Ocr,
|
||
RegionOfInterest = roi
|
||
}).Text);
|
||
Logger.LogInformation("切换队伍,当前队伍名称: {Text},使用正则表达式规则进行模糊匹配", text);
|
||
if (Regex.IsMatch(text, partyName))
|
||
{
|
||
Logger.LogInformation("当前队伍[{Name}]即为目标队伍,无需切换", text);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在当前页的文字区域列表中查找匹配目标的队伍
|
||
/// </summary>
|
||
private (Region? match, double score) FindMatchInPage(
|
||
ImageRegion page, List<Region> textRegions, string partyName, bool useOcrMatch)
|
||
{
|
||
if (useOcrMatch)
|
||
{
|
||
var matchService = OcrFactory.PaddleMatch;
|
||
var threshold = TaskContext.Instance().Config.OtherConfig.OcrConfig.OcrMatchDefaultThreshold;
|
||
Region? bestMatch = null;
|
||
double bestScore = 0;
|
||
var imgW = page.SrcMat.Width;
|
||
var imgH = page.SrcMat.Height;
|
||
foreach (var region in textRegions)
|
||
{
|
||
var cx = Math.Max(0, region.X);
|
||
var cy = Math.Max(0, region.Y);
|
||
var cw = Math.Min(region.Width, imgW - cx);
|
||
var ch = Math.Min(region.Height, imgH - cy);
|
||
if (cw <= 0 || ch <= 0)
|
||
continue;
|
||
|
||
using var cropped = page.DeriveCrop(cx, cy, cw, ch);
|
||
var score = matchService.OcrMatchDirect(cropped.SrcMat, partyName);
|
||
if (score >= threshold && score > bestScore)
|
||
{
|
||
bestScore = score;
|
||
bestMatch = region;
|
||
}
|
||
}
|
||
|
||
return (bestMatch, bestScore);
|
||
}
|
||
|
||
foreach (var region in textRegions)
|
||
{
|
||
if (Regex.IsMatch(region.Text, partyName))
|
||
return (region, 0);
|
||
}
|
||
|
||
return (null, 0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 打开队伍选择页面(点击选择按钮并等待加载)
|
||
/// </summary>
|
||
private static async Task<Region> OpenPartyChoosePage(Region partyViewBtn, CancellationToken ct)
|
||
{
|
||
var menu = await NewRetry.WaitForElementAppear(
|
||
ElementAssets.Instance.PartyBtnDelete,
|
||
() => partyViewBtn.Click(),
|
||
ct, 4, 500);
|
||
if (!menu)
|
||
throw new PartySetupFailedException("未能打开队伍选择页面");
|
||
|
||
Region? partyDeleteBtn = null;
|
||
var success = await NewRetry.WaitForAction(() =>
|
||
{
|
||
using var ocrRa = CaptureToRectArea();
|
||
partyDeleteBtn = ocrRa.Find(ElementAssets.Instance.PartyBtnDelete);
|
||
return partyDeleteBtn.IsExist();
|
||
}, ct, 5);
|
||
|
||
if (!success || partyDeleteBtn == null)
|
||
throw new PartySetupFailedException("未能打开队伍配置界面");
|
||
|
||
return partyDeleteBtn;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 滚动列表到最上方
|
||
/// </summary>
|
||
private static async Task ScrollToTop(CancellationToken ct)
|
||
{
|
||
await Task.Delay(50, ct);
|
||
GameCaptureRegion.GameRegion1080PPosClick(700, 125);
|
||
await Task.Delay(50, ct);
|
||
Simulation.SendInput.Mouse.LeftButtonDown();
|
||
await Task.Delay(450, ct);
|
||
Simulation.SendInput.Mouse.LeftButtonUp();
|
||
await Task.Delay(100, ct);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 清理 OCR 识别结果中的干扰字符
|
||
/// </summary>
|
||
private static string CleanOcrText(string? text)
|
||
{
|
||
if (string.IsNullOrEmpty(text))
|
||
return string.Empty;
|
||
var cleaned = text.Replace("\"", "").Replace("\r\n", "").Replace("\r", "");
|
||
var newLineIndex = cleaned.IndexOf('\n');
|
||
if (newLineIndex != -1)
|
||
cleaned = cleaned[..newLineIndex];
|
||
return cleaned.Trim();
|
||
}
|
||
|
||
private async Task ConfirmParty(ImageRegion page, CancellationToken ct, bool isInPartyViewUi = false)
|
||
{
|
||
Bv.ClickWhiteConfirmButton(page.DeriveCrop(0, page.Height / 4, page.Width / 4, page.Height - page.Height / 4));
|
||
var partyChooseUiClosed = await NewRetry.WaitForAction(() =>
|
||
{
|
||
using var ra2 = CaptureToRectArea();
|
||
return ra2.Find(ElementAssets.Instance.PartyBtnDelete).IsEmpty();
|
||
}, ct, 10);
|
||
if (!partyChooseUiClosed)
|
||
{
|
||
throw new PartySetupFailedException("选择队伍失败,等待队伍切换超时!");
|
||
}
|
||
|
||
await Delay(200, ct);
|
||
using var ra = CaptureToRectArea();
|
||
Bv.ClickWhiteConfirmButton(ra.DeriveCrop(page.Width - page.Width / 4, page.Height / 4, page.Width / 4, page.Height - page.Height / 4));
|
||
await Delay(500, ct);
|
||
if (isInPartyViewUi) await _returnMainUiTask.Start(ct);
|
||
}
|
||
}
|