Files
better-genshin-impact/Test/BetterGenshinImpact.UnitTest/CoreTests/RecognitionTests/OCRTests/OcrUtilsTests.cs
Takaranoao e9d11f7267 文本识别的模糊匹配功能 (#2799)
* 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,并优化相关逻辑
2026-02-20 15:08:46 +08:00

292 lines
9.0 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.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[" "]);
// 所有标签都是长度1labelLengths = [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 2labels 中第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
}