Files
better-genshin-impact/Test/BetterGenshinImpact.UnitTest/CoreTests/RecognitionTests/OCRTests/OcrMatchFallbackServiceTests.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

196 lines
5.8 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;
using OpenCvSharp;
namespace BetterGenshinImpact.UnitTest.CoreTests.RecognitionTests.OCRTests;
public class OcrMatchFallbackServiceTests
{
#region LevenshteinDistance
[Fact]
public void LevenshteinDistance_IdenticalStrings_ReturnsZero()
{
Assert.Equal(0, OcrMatchFallbackService.LevenshteinDistance("abc", "abc"));
}
[Fact]
public void LevenshteinDistance_EmptyAndNonEmpty_ReturnsLength()
{
Assert.Equal(3, OcrMatchFallbackService.LevenshteinDistance("", "abc"));
Assert.Equal(3, OcrMatchFallbackService.LevenshteinDistance("abc", ""));
}
[Fact]
public void LevenshteinDistance_BothEmpty_ReturnsZero()
{
Assert.Equal(0, OcrMatchFallbackService.LevenshteinDistance("", ""));
}
[Fact]
public void LevenshteinDistance_SingleSubstitution()
{
// "确认" vs "确忍" — 一个字符替换
Assert.Equal(1, OcrMatchFallbackService.LevenshteinDistance("确认", "确忍"));
}
[Fact]
public void LevenshteinDistance_Insertion()
{
Assert.Equal(1, OcrMatchFallbackService.LevenshteinDistance("ac", "abc"));
}
[Fact]
public void LevenshteinDistance_Deletion()
{
Assert.Equal(1, OcrMatchFallbackService.LevenshteinDistance("abc", "ac"));
}
[Fact]
public void LevenshteinDistance_CompletelyDifferent()
{
Assert.Equal(3, OcrMatchFallbackService.LevenshteinDistance("abc", "xyz"));
}
#endregion
#region ComputeTextSimilarity
[Fact]
public void ComputeTextSimilarity_ExactMatch_ReturnsOne()
{
Assert.Equal(1.0, OcrMatchFallbackService.ComputeTextSimilarity("确认", "确认"));
}
[Fact]
public void ComputeTextSimilarity_TextContainsTarget_ReturnsOne()
{
// "确认购买" 包含 "确认"
Assert.Equal(1.0, OcrMatchFallbackService.ComputeTextSimilarity("确认购买", "确认"));
}
[Fact]
public void ComputeTextSimilarity_TargetContainsText_ReturnsRatio()
{
// "确认" 被 "确认购买" 包含,长度比 = 2/4
Assert.Equal(0.5, OcrMatchFallbackService.ComputeTextSimilarity("确认", "确认购买"));
}
[Fact]
public void ComputeTextSimilarity_EmptyTarget_ReturnsOne()
{
Assert.Equal(1.0, OcrMatchFallbackService.ComputeTextSimilarity("任意文字", ""));
}
[Fact]
public void ComputeTextSimilarity_EmptyText_ReturnsZero()
{
Assert.Equal(0.0, OcrMatchFallbackService.ComputeTextSimilarity("", "确认"));
}
[Fact]
public void ComputeTextSimilarity_SingleCharDifference()
{
// "确忍" vs "确认" — 距离1, 最大长度2, 相似度 = 1 - 1/2 = 0.5
Assert.Equal(0.5, OcrMatchFallbackService.ComputeTextSimilarity("确忍", "确认"));
}
[Fact]
public void ComputeTextSimilarity_CompletelyDifferent_ReturnsZero()
{
// 完全不同的字符串
Assert.Equal(0.0, OcrMatchFallbackService.ComputeTextSimilarity("甲乙", "丙丁"));
}
#endregion
#region OcrMatch / OcrMatchDirect 使 FakeOcrService
[Fact]
public void OcrMatch_WhenRegionContainsTarget_ReturnsOne()
{
var fakeOcr = new FakeOcrService(new OcrResult([
new OcrResultRegion(default, "确认购买", 0.9f)
]));
var sut = new OcrMatchFallbackService(fakeOcr);
using var mat = new Mat(50, 200, MatType.CV_8UC3, Scalar.White);
var score = sut.OcrMatch(mat, "确认");
Assert.Equal(1.0, score);
}
[Fact]
public void OcrMatch_MultipleRegions_ReturnsBestScore()
{
var fakeOcr = new FakeOcrService(new OcrResult([
new OcrResultRegion(default, "其他文字", 0.9f),
new OcrResultRegion(default, "确认", 0.9f)
]));
var sut = new OcrMatchFallbackService(fakeOcr);
using var mat = new Mat(50, 200, MatType.CV_8UC3, Scalar.White);
var score = sut.OcrMatch(mat, "确认");
Assert.Equal(1.0, score);
}
[Fact]
public void OcrMatch_NoRegions_ReturnsZero()
{
var fakeOcr = new FakeOcrService(new OcrResult([]));
var sut = new OcrMatchFallbackService(fakeOcr);
using var mat = new Mat(50, 200, MatType.CV_8UC3, Scalar.White);
var score = sut.OcrMatch(mat, "确认");
Assert.Equal(0.0, score);
}
[Fact]
public void OcrMatchDirect_ExactMatch_ReturnsOne()
{
var fakeOcr = new FakeOcrService(ocrWithoutDetectorResult: "确认");
var sut = new OcrMatchFallbackService(fakeOcr);
using var mat = new Mat(50, 200, MatType.CV_8UC3, Scalar.White);
var score = sut.OcrMatchDirect(mat, "确认");
Assert.Equal(1.0, score);
}
[Fact]
public void OcrMatchDirect_PartialMatch_ReturnsPartialScore()
{
var fakeOcr = new FakeOcrService(ocrWithoutDetectorResult: "确忍");
var sut = new OcrMatchFallbackService(fakeOcr);
using var mat = new Mat(50, 200, MatType.CV_8UC3, Scalar.White);
var score = sut.OcrMatchDirect(mat, "确认");
Assert.Equal(0.5, score, 0.01);
}
#endregion
/// <summary>
/// 用于测试 OcrMatchFallbackService 的假 IOcrService。
/// </summary>
private class FakeOcrService : IOcrService
{
private readonly OcrResult? _ocrResult;
private readonly string _ocrWithoutDetectorResult;
public FakeOcrService(OcrResult? ocrResult = null, string ocrWithoutDetectorResult = "")
{
_ocrResult = ocrResult;
_ocrWithoutDetectorResult = ocrWithoutDetectorResult;
}
public string Ocr(Mat mat) => _ocrResult?.Text ?? "";
public string OcrWithoutDetector(Mat mat) => _ocrWithoutDetectorResult;
public OcrResult OcrResult(Mat mat) => _ocrResult ?? new OcrResult([]);
}
}