mirror of
https://github.com/babalae/better-genshin-impact.git
synced 2026-03-15 07:43:20 +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,并优化相关逻辑
292 lines
9.0 KiB
C#
292 lines
9.0 KiB
C#
using BetterGenshinImpact.Core.Recognition.OCR.Engine;
|
||
|
||
namespace BetterGenshinImpact.UnitTest.CoreTests.RecognitionTests.OCRTests;
|
||
|
||
public class OcrUtilsTests
|
||
{
|
||
#region CreateLabelDict
|
||
|
||
[Fact]
|
||
public void CreateLabelDict_SingleCharLabels_MapsCorrectly()
|
||
{
|
||
// 标签 ["a","b","c"] → a=1, b=2, c=3, " "=4
|
||
IReadOnlyList<string> labels = ["a", "b", "c"];
|
||
var dict = OcrUtils.CreateLabelDict(labels, out var lengths);
|
||
|
||
Assert.Equal(1, dict["a"]);
|
||
Assert.Equal(2, dict["b"]);
|
||
Assert.Equal(3, dict["c"]);
|
||
Assert.Equal(4, dict[" "]);
|
||
// 所有标签都是长度1,labelLengths = [1]
|
||
Assert.Single(lengths);
|
||
Assert.Equal(1, lengths[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateLabelDict_NoZeroLength()
|
||
{
|
||
// 不应包含长度为0的项(防止无限循环)
|
||
IReadOnlyList<string> labels = ["x", "y"];
|
||
OcrUtils.CreateLabelDict(labels, out var lengths);
|
||
Assert.DoesNotContain(0, lengths);
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateLabelDict_LengthsDescendingOrder()
|
||
{
|
||
// 多字节标签时,labelLengths 应降序排列(先试长匹配)
|
||
IReadOnlyList<string> labels = ["a", "ab", "b"];
|
||
OcrUtils.CreateLabelDict(labels, out var lengths);
|
||
for (var i = 0; i < lengths.Length - 1; i++)
|
||
{
|
||
Assert.True(lengths[i] >= lengths[i + 1], "labelLengths 应为降序");
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region MapStringToLabelIndices
|
||
|
||
[Fact]
|
||
public void MapStringToLabelIndices_SimpleMatch()
|
||
{
|
||
// labels: ["a","b","c"] → a=1, b=2, c=3
|
||
IReadOnlyList<string> labels = ["a", "b", "c"];
|
||
var dict = OcrUtils.CreateLabelDict(labels, out var lengths);
|
||
|
||
var result = OcrUtils.MapStringToLabelIndices("abc", dict, lengths);
|
||
|
||
Assert.Equal([1, 2, 3], result);
|
||
}
|
||
|
||
[Fact]
|
||
public void MapStringToLabelIndices_SkipsUnknownChars()
|
||
{
|
||
// "aXb" 中 X 不在标签里,应被跳过
|
||
IReadOnlyList<string> labels = ["a", "b"];
|
||
var dict = OcrUtils.CreateLabelDict(labels, out var lengths);
|
||
|
||
var result = OcrUtils.MapStringToLabelIndices("aXb", dict, lengths);
|
||
|
||
Assert.Equal([1, 2], result);
|
||
}
|
||
|
||
[Fact]
|
||
public void MapStringToLabelIndices_PrefersLongerMatch()
|
||
{
|
||
// 标签含 "ab" 和 "a",输入 "ab" 应优先匹配长标签 "ab"
|
||
IReadOnlyList<string> labels = ["a", "ab", "b"];
|
||
var dict = OcrUtils.CreateLabelDict(labels, out var lengths);
|
||
|
||
var result = OcrUtils.MapStringToLabelIndices("ab", dict, lengths);
|
||
|
||
// "ab" 整体匹配为 index 2(labels 中第2个元素)
|
||
Assert.Single(result);
|
||
Assert.Equal(2, result[0]);
|
||
}
|
||
|
||
[Fact]
|
||
public void MapStringToLabelIndices_EmptyString_ReturnsEmpty()
|
||
{
|
||
IReadOnlyList<string> labels = ["a", "b"];
|
||
var dict = OcrUtils.CreateLabelDict(labels, out var lengths);
|
||
|
||
var result = OcrUtils.MapStringToLabelIndices("", dict, lengths);
|
||
|
||
Assert.Empty(result);
|
||
}
|
||
|
||
[Fact]
|
||
public void MapStringToLabelIndices_AllUnknown_ReturnsEmpty()
|
||
{
|
||
IReadOnlyList<string> labels = ["a", "b"];
|
||
var dict = OcrUtils.CreateLabelDict(labels, out var lengths);
|
||
|
||
var result = OcrUtils.MapStringToLabelIndices("XYZ", dict, lengths);
|
||
|
||
Assert.Empty(result);
|
||
}
|
||
|
||
[Fact]
|
||
public void MapStringToLabelIndices_SpaceChar_MapsToSpaceIndex()
|
||
{
|
||
// 空格字符映射到 labels.Count + 1
|
||
IReadOnlyList<string> labels = ["a", "b"];
|
||
var dict = OcrUtils.CreateLabelDict(labels, out var lengths);
|
||
|
||
var result = OcrUtils.MapStringToLabelIndices("a b", dict, lengths);
|
||
|
||
// a=1, " "=3, b=2
|
||
Assert.Equal([1, 3, 2], result);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region GetMaxScoreDP
|
||
|
||
[Fact]
|
||
public void GetMaxScoreDP_PerfectMatch_ReturnsFullScore()
|
||
{
|
||
// result 中按顺序包含 target 的所有元素,置信度均为 1.0
|
||
(int, float)[] result = [(1, 1.0f), (2, 1.0f), (3, 1.0f)];
|
||
int[] target = [1, 2, 3];
|
||
|
||
var score = OcrUtils.GetMaxScoreDp(result, target, target.Length);
|
||
|
||
Assert.Equal(1.0, score);
|
||
}
|
||
|
||
[Fact]
|
||
public void GetMaxScoreDP_NoMatch_ReturnsZero()
|
||
{
|
||
// result 中不包含 target 的任何元素
|
||
(int, float)[] result = [(4, 1.0f), (5, 1.0f)];
|
||
int[] target = [1, 2];
|
||
|
||
var score = OcrUtils.GetMaxScoreDp(result, target, target.Length);
|
||
|
||
Assert.Equal(0, score);
|
||
}
|
||
|
||
[Fact]
|
||
public void GetMaxScoreDP_EmptyTarget_ReturnsZero()
|
||
{
|
||
(int, float)[] result = [(1, 1.0f)];
|
||
int[] target = [];
|
||
|
||
var score = OcrUtils.GetMaxScoreDp(result, target, 1);
|
||
|
||
Assert.Equal(0, score);
|
||
}
|
||
|
||
[Fact]
|
||
public void GetMaxScoreDP_PartialMatch_ReturnsZero()
|
||
{
|
||
// target 需要 [1,2,3],但 result 只有 [1,2],无法完整匹配
|
||
(int, float)[] result = [(1, 1.0f), (2, 1.0f)];
|
||
int[] target = [1, 2, 3];
|
||
|
||
var score = OcrUtils.GetMaxScoreDp(result, target, target.Length);
|
||
|
||
Assert.Equal(0, score);
|
||
}
|
||
|
||
[Fact]
|
||
public void GetMaxScoreDP_SubsequenceMatch_SkipsNoise()
|
||
{
|
||
// result 中有噪声,但子序列 [1,2,3] 可匹配
|
||
(int, float)[] result = [(9, 0.5f), (1, 0.8f), (9, 0.3f), (2, 0.9f), (3, 0.7f)];
|
||
int[] target = [1, 2, 3];
|
||
|
||
var score = OcrUtils.GetMaxScoreDp(result, target, target.Length);
|
||
|
||
// (0.8 + 0.9 + 0.7) / 3 = 0.8
|
||
Assert.Equal(0.8, score, 0.01);
|
||
}
|
||
|
||
[Fact]
|
||
public void GetMaxScoreDP_PicksBestConfidence()
|
||
{
|
||
// target [1],result 中有两个 index=1,应选置信度最高的
|
||
(int, float)[] result = [(1, 0.3f), (1, 0.9f)];
|
||
int[] target = [1];
|
||
|
||
var score = OcrUtils.GetMaxScoreDp(result, target, 1);
|
||
|
||
Assert.Equal(0.9, score, 0.01);
|
||
}
|
||
|
||
[Fact]
|
||
public void GetMaxScoreDP_NormalizesWithAvailableCount()
|
||
{
|
||
// availableCount > target.Length 时分数被稀释
|
||
(int, float)[] result = [(1, 1.0f), (2, 1.0f)];
|
||
int[] target = [1, 2];
|
||
|
||
var score = OcrUtils.GetMaxScoreDp(result, target, 4);
|
||
|
||
// (1.0 + 1.0) / 4 = 0.5
|
||
Assert.Equal(0.5, score, 0.01);
|
||
}
|
||
|
||
[Fact]
|
||
public void GetMaxScoreDP_ManyFrames_TargetLengthDenominator_ScoresHigh()
|
||
{
|
||
// 模拟多个文字区域的字符帧合并后做匹配,分母应为 target.Length
|
||
// 即使有很多噪声帧,只要 target 完整匹配,分数仍应很高
|
||
(int, float)[] result = [
|
||
(9, 0.5f), (8, 0.6f), (7, 0.4f), // 噪声区域1
|
||
(1, 0.9f), (2, 0.85f), // 匹配目标 [1,2]
|
||
(6, 0.7f), (5, 0.3f), (4, 0.5f), // 噪声区域2
|
||
(9, 0.2f), (8, 0.4f) // 噪声区域3
|
||
];
|
||
int[] target = [1, 2];
|
||
|
||
// 使用 target.Length 作为分母:(0.9 + 0.85) / 2 = 0.875
|
||
var score = OcrUtils.GetMaxScoreDp(result, target, target.Length);
|
||
|
||
Assert.Equal(0.875, score, 0.01);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region CreateWeights
|
||
|
||
[Fact]
|
||
public void CreateWeights_DefaultsToOne()
|
||
{
|
||
IReadOnlyList<string> labels = ["a", "b", "c"];
|
||
var labelDict = OcrUtils.CreateLabelDict(labels, out _);
|
||
var weights = OcrUtils.CreateWeights(new Dictionary<string, float>(), labelDict, labels.Count);
|
||
|
||
// labels.Count + 2 = 5
|
||
Assert.Equal(5, weights.Length);
|
||
Assert.All(weights, w => Assert.Equal(1.0f, w));
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateWeights_AppliesExtraWeights()
|
||
{
|
||
IReadOnlyList<string> labels = ["a", "b", "c"];
|
||
var extra = new Dictionary<string, float> { { "b", 2.5f } };
|
||
var labelDict = OcrUtils.CreateLabelDict(labels, out _);
|
||
|
||
var weights = OcrUtils.CreateWeights(extra, labelDict, labels.Count);
|
||
|
||
// "b" 是 labels[1],index=2
|
||
Assert.Equal(1.0f, weights[1]); // "a"
|
||
Assert.Equal(2.5f, weights[2]); // "b"
|
||
Assert.Equal(1.0f, weights[3]); // "c"
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateWeights_IgnoresUnknownKeys()
|
||
{
|
||
IReadOnlyList<string> labels = ["a", "b"];
|
||
var extra = new Dictionary<string, float> { { "z", 5.0f } };
|
||
var labelDict = OcrUtils.CreateLabelDict(labels, out _);
|
||
|
||
var weights = OcrUtils.CreateWeights(extra, labelDict, labels.Count);
|
||
|
||
Assert.All(weights, w => Assert.Equal(1.0f, w));
|
||
}
|
||
|
||
[Fact]
|
||
public void CreateWeights_SpaceKey_MapsToCorrectIndex()
|
||
{
|
||
// 空格权重应写入 labels.Count + 1 位置,与 CreateLabelDict 一致
|
||
IReadOnlyList<string> labels = ["a", " ", "b"];
|
||
var extra = new Dictionary<string, float> { { " ", 3.0f } };
|
||
var labelDict = OcrUtils.CreateLabelDict(labels, out _);
|
||
|
||
var weights = OcrUtils.CreateWeights(extra, labelDict, labels.Count);
|
||
|
||
// labels.Count + 1 = 4,空格权重应在 weights[4]
|
||
Assert.Equal(3.0f, weights[labels.Count + 1]);
|
||
// labels 中 " " 的位置 index=2(即 weights[2])不应被错误写入
|
||
Assert.Equal(1.0f, weights[2]);
|
||
}
|
||
|
||
#endregion
|
||
}
|