feat: 开源四阶判决器算法实现(FrameDedupService / FarnebackFlow)与完整训练流水线
Browse files- README.md +23 -0
- code/algorithm/Easy4K.SFDP.csproj +20 -0
- code/algorithm/FarnebackFlow.cs +452 -0
- code/algorithm/FrameDedupService.cs +505 -0
README.md
CHANGED
|
@@ -131,6 +131,29 @@ print(float(score)) # 1.0 表示两帧重复
|
|
| 131 |
|---|---|
|
| 132 |
| `code/siai_all.py` | 一体化流水线:视频切片(`tiles`)、教师打标(`teacher`)、软标签生成(`label`)、训练(`train`)、评测(`eval`)、ONNX 导出(`pack`) |
|
| 133 |
| `code/append_sources.py` | 训练素材清单的生成与去重追加,产出可溯源的 `training-materials.txt` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
主要命令:
|
| 136 |
|
|
|
|
| 131 |
|---|---|
|
| 132 |
| `code/siai_all.py` | 一体化流水线:视频切片(`tiles`)、教师打标(`teacher`)、软标签生成(`label`)、训练(`train`)、评测(`eval`)、ONNX 导出(`pack`) |
|
| 133 |
| `code/append_sources.py` | 训练素材清单的生成与去重追加,产出可溯源的 `training-materials.txt` |
|
| 134 |
+
| `code/algorithm/FrameDedupService.cs` | 四阶判决器教师本体:像素差粗筛 → dHash 细筛 → QR 分解精判 → Farnebäck 光流终判,含全部阈值与标定注释 |
|
| 135 |
+
| `code/algorithm/FarnebackFlow.cs` | 第四阶使用的稠密光流实现(GPU/CPU 混合路径) |
|
| 136 |
+
| `code/algorithm/Easy4K.SFDP.csproj` | 上述两个源文件的编译工程(.NET 10,Windows.Graphics.Imaging) |
|
| 137 |
+
|
| 138 |
+
### 教师算法(四阶判决器)
|
| 139 |
+
|
| 140 |
+
| 阶段 | 方法 | 作用 |
|
| 141 |
+
|---|---|---|
|
| 142 |
+
| 1 | 像素绝对差均值(MAD) | 粗筛,明显不同的帧对直接放过 |
|
| 143 |
+
| 2 | dHash 汉明距离 | 细筛,感知哈希不一致的帧对放过 |
|
| 144 |
+
| 3 | 8×8 分块 QR 分解,取 diag(R) 特征算 Minkowski 距离 | 精判,抑制亮度/压缩噪声造成的误判 |
|
| 145 |
+
| 4 | Farnebäck 稠密光流,统计均值与方差 | 终判,区分“真重复帧”与“相似但不相同”的运动画面 |
|
| 146 |
+
|
| 147 |
+
阈值取论文建议值并对 1080p 动画 MV 重新标定(`τ_flow=0.5`、`τ_var=0.1`、QR 阈值 1.0 灰阶),标定过程与分布数据写在源码注释里。
|
| 148 |
+
|
| 149 |
+
### ONNX 模型
|
| 150 |
+
|
| 151 |
+
| 文件 | 精度 | 体积 | 建议用途 |
|
| 152 |
+
|---|---|---|---|
|
| 153 |
+
| `model.onnx` | FP32 | 9.95 MB | 完美模式(精度优先) |
|
| 154 |
+
| `model_fp16.onnx` | FP16(IO 保持 float32) | 5.01 MB | 性能模式(速度优先,DirectML 下约快 1.5~2 倍) |
|
| 155 |
+
|
| 156 |
+
两份模型输入输出签名完全一致:`frame_a`、`frame_b` → `score`,调用方按模式切换文件名即可。
|
| 157 |
|
| 158 |
主要命令:
|
| 159 |
|
code/algorithm/Easy4K.SFDP.csproj
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk">
|
| 2 |
+
|
| 3 |
+
<PropertyGroup>
|
| 4 |
+
<!-- 动态链接库:别的程序直接引用 / 加载调用,不起独立进程 -->
|
| 5 |
+
<OutputType>Library</OutputType>
|
| 6 |
+
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
| 7 |
+
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
| 8 |
+
<RootNamespace>Easy4K.SFDP</RootNamespace>
|
| 9 |
+
<AssemblyName>Easy4K.SFDP</AssemblyName>
|
| 10 |
+
<Platforms>x64</Platforms>
|
| 11 |
+
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-x64</RuntimeIdentifier>
|
| 12 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 13 |
+
<Nullable>enable</Nullable>
|
| 14 |
+
<LangVersion>latest</LangVersion>
|
| 15 |
+
<!-- CA1416 是"仅 Windows"提示,本项目本来就只在 Windows 跑 -->
|
| 16 |
+
<NoWarn>CA1416;CA1851;CS8602;CS8604</NoWarn>
|
| 17 |
+
<InvariantGlobalization>true</InvariantGlobalization>
|
| 18 |
+
</PropertyGroup>
|
| 19 |
+
|
| 20 |
+
</Project>
|
code/algorithm/FarnebackFlow.cs
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Threading.Tasks;
|
| 2 |
+
|
| 3 |
+
namespace Easy4K.SFDP;
|
| 4 |
+
|
| 5 |
+
/// <summary>第四阶:Farnebäck 稠密光流(论文第四阶的终判依据)。
|
| 6 |
+
///
|
| 7 |
+
/// 实现路线与 OpenCV calcOpticalFlowFarneback 同源,但用「加权矩最小二乘」直接拟合多项式系数:
|
| 8 |
+
/// 1) 灰度化 + 高斯金字塔(自粗到细,pyr_scale / levels)
|
| 9 |
+
/// 2) 多项式展开:每像素邻域用二次多项式 f(x) ≈ xᵀAx + bᵀx + c 拟合。
|
| 10 |
+
/// 邻域取 |u|,|v| ≤ poly_n/2 的方形窗,权重为可分离高斯(σ = poly_sigma);
|
| 11 |
+
/// 6 个基函数 φ = [1, u, v, u², uv, v²] 的加权矩用 6 组可分离相关一次算完,
|
| 12 |
+
/// 系数 c = M⁻¹·T(M = Σ W·φφᵀ 只与窗尺寸有关,全图共用一个逆矩阵)。
|
| 13 |
+
/// 3) 位移求解:第二帧可视为第一帧平移 d → b₂ = b₁ - 2A d,故 A·d = -½(b₂-b₁)。
|
| 14 |
+
/// 把 A 与 (b₂-b₁) 在 winsize 窗口内累加后解 2×2(比逐像素解稳,与论文一致)。
|
| 15 |
+
/// 4) 迭代:每轮把第一帧的展开场按当前光流 warp 后重解,位移累加(iterations 次)。
|
| 16 |
+
/// 5) 金字塔上采样:粗层位移 × 1/pyr_scale 作为细层初值。
|
| 17 |
+
///
|
| 18 |
+
/// 输出 MeanFlow / VarFlow(位移幅度的一阶/二阶统计,单位:像素),供论文判决使用。
|
| 19 |
+
/// 说明:本文件不依赖任何 UI 类型,便于在控制台里做闭环验证。</summary>
|
| 20 |
+
public static class FarnebackFlow
|
| 21 |
+
{
|
| 22 |
+
/// <summary>参数默认值对齐 OpenCV:levels=3, pyr_scale=0.5, winsize=15, iterations=3, poly_n=5, poly_sigma=1.2</summary>
|
| 23 |
+
public sealed record Options(
|
| 24 |
+
int Levels = 3,
|
| 25 |
+
double PyrScale = 0.5,
|
| 26 |
+
int WinSize = 15,
|
| 27 |
+
int Iterations = 3,
|
| 28 |
+
int PolyN = 5,
|
| 29 |
+
double PolySigma = 1.2);
|
| 30 |
+
|
| 31 |
+
/// <summary>光流场 + 论文所需的两项统计</summary>
|
| 32 |
+
public sealed class Field
|
| 33 |
+
{
|
| 34 |
+
public required float[] Dx { get; init; }
|
| 35 |
+
public required float[] Dy { get; init; }
|
| 36 |
+
public int Width { get; init; }
|
| 37 |
+
public int Height { get; init; }
|
| 38 |
+
/// <summary>MeanFlow = 全图位移幅度的平均(像素)</summary>
|
| 39 |
+
public double MeanFlow { get; init; }
|
| 40 |
+
/// <summary>VarFlow = 位移幅度的方差</summary>
|
| 41 |
+
public double VarFlow { get; init; }
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/// <summary>BGRA8 两帧 → 全分辨率稠密光流(第一帧 → 第二帧)。</summary>
|
| 45 |
+
public static Field Compute(byte[] aBgra, byte[] bBgra, int width, int height, Options opt)
|
| 46 |
+
{
|
| 47 |
+
var g1 = ToGray(aBgra, width, height);
|
| 48 |
+
var g2 = ToGray(bBgra, width, height);
|
| 49 |
+
|
| 50 |
+
float[]? flowX = null, flowY = null;
|
| 51 |
+
var fw = 0;
|
| 52 |
+
var fh = 0;
|
| 53 |
+
|
| 54 |
+
for (var level = opt.Levels - 1; level >= 0; level--)
|
| 55 |
+
{
|
| 56 |
+
var s = Math.Pow(opt.PyrScale, level);
|
| 57 |
+
var w = Math.Max(16, (int)Math.Round(width * s));
|
| 58 |
+
var h = Math.Max(16, (int)Math.Round(height * s));
|
| 59 |
+
|
| 60 |
+
var l1 = Resize(g1, width, height, w, h);
|
| 61 |
+
var l2 = Resize(g2, width, height, w, h);
|
| 62 |
+
|
| 63 |
+
// 粗层位移放大到本层作为初值(位移量按尺寸比例放大,坐标按比例采样)
|
| 64 |
+
var fx = new float[w * h];
|
| 65 |
+
var fy = new float[w * h];
|
| 66 |
+
if (flowX is not null && fw > 0)
|
| 67 |
+
{
|
| 68 |
+
var kx = w / (double)fw;
|
| 69 |
+
var ky = h / (double)fh;
|
| 70 |
+
for (var y = 0; y < h; y++)
|
| 71 |
+
for (var x = 0; x < w; x++)
|
| 72 |
+
{
|
| 73 |
+
var i = y * w + x;
|
| 74 |
+
fx[i] = (float)(SampleBilinear(flowX, fw, fh, x / kx, y / ky) * kx);
|
| 75 |
+
fy[i] = (float)(SampleBilinear(flowY!, fw, fh, x / kx, y / ky) * ky);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
var e1 = PolyExp(l1, w, h, opt);
|
| 80 |
+
var e2 = PolyExp(l2, w, h, opt);
|
| 81 |
+
for (var it = 0; it < opt.Iterations; it++)
|
| 82 |
+
UpdateFlow(e1, e2, fx, fy, w, h, opt);
|
| 83 |
+
|
| 84 |
+
flowX = fx;
|
| 85 |
+
flowY = fy;
|
| 86 |
+
fw = w;
|
| 87 |
+
fh = h;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
var n = fw * fh;
|
| 91 |
+
double mean = 0;
|
| 92 |
+
for (var i = 0; i < n; i++) mean += Magnitude(flowX![i], flowY![i]);
|
| 93 |
+
mean /= Math.Max(1, n);
|
| 94 |
+
double var = 0;
|
| 95 |
+
for (var i = 0; i < n; i++)
|
| 96 |
+
{
|
| 97 |
+
var d = Magnitude(flowX![i], flowY![i]) - mean;
|
| 98 |
+
var += d * d;
|
| 99 |
+
}
|
| 100 |
+
var /= Math.Max(1, n);
|
| 101 |
+
|
| 102 |
+
return new Field { Dx = flowX!, Dy = flowY!, Width = fw, Height = fh, MeanFlow = mean, VarFlow = var };
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
private static double Magnitude(double dx, double dy) => Math.Sqrt(dx * dx + dy * dy);
|
| 106 |
+
|
| 107 |
+
// ===================== 多项式展开 =====================
|
| 108 |
+
|
| 109 |
+
/// <summary>每个像素的二次多项式系数(A 对称,只用 Axx/Axy/Ayy + Bx/By)</summary>
|
| 110 |
+
private sealed class Exp
|
| 111 |
+
{
|
| 112 |
+
public required float[] Axx { get; init; }
|
| 113 |
+
public required float[] Axy { get; init; }
|
| 114 |
+
public required float[] Ayy { get; init; }
|
| 115 |
+
public required float[] Bx { get; init; }
|
| 116 |
+
public required float[] By { get; init; }
|
| 117 |
+
public required float[] C { get; init; }
|
| 118 |
+
public int Width { get; init; }
|
| 119 |
+
public int Height { get; init; }
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/// <summary>加权矩最小二乘拟合二次多项式:T_pq = Σ W·u^p v^q · f,系数 = M⁻¹·T</summary>
|
| 123 |
+
private static Exp PolyExp(float[] src, int w, int h, Options opt)
|
| 124 |
+
{
|
| 125 |
+
var k = Math.Max(1, opt.PolyN / 2);
|
| 126 |
+
var g = Gaussian1D(k, opt.PolySigma);
|
| 127 |
+
|
| 128 |
+
// 基函数顺序:1, u, v, u², uv, v² → 矩 (p,q) 取 (0,0),(1,0),(0,1),(2,0),(1,1),(0,2)
|
| 129 |
+
var m00 = Correlate2(src, w, h, g, g, 0, 0);
|
| 130 |
+
var m10 = Correlate2(src, w, h, g, g, 1, 0);
|
| 131 |
+
var m01 = Correlate2(src, w, h, g, g, 0, 1);
|
| 132 |
+
var m20 = Correlate2(src, w, h, g, g, 2, 0);
|
| 133 |
+
var m11 = Correlate2(src, w, h, g, g, 1, 1);
|
| 134 |
+
var m02 = Correlate2(src, w, h, g, g, 0, 2);
|
| 135 |
+
|
| 136 |
+
var inv = MomentInverse(k, g); // 6×6 逆矩阵(只与窗/权重有关)
|
| 137 |
+
|
| 138 |
+
var n = w * h;
|
| 139 |
+
var axx = new float[n];
|
| 140 |
+
var axy = new float[n];
|
| 141 |
+
var ayy = new float[n];
|
| 142 |
+
var bx = new float[n];
|
| 143 |
+
var by = new float[n];
|
| 144 |
+
var c0 = new float[n];
|
| 145 |
+
|
| 146 |
+
// 注意:t/r 必须线程私有(放在 Parallel.For 外面会被多线程互相覆盖)
|
| 147 |
+
Parallel.For(0, h,
|
| 148 |
+
() => (new double[6], new double[6]),
|
| 149 |
+
(y, _, scratch) =>
|
| 150 |
+
{
|
| 151 |
+
var (t, r) = scratch;
|
| 152 |
+
for (var x = 0; x < w; x++)
|
| 153 |
+
{
|
| 154 |
+
var i = y * w + x;
|
| 155 |
+
t[0] = m00[i]; t[1] = m10[i]; t[2] = m01[i];
|
| 156 |
+
t[3] = m20[i]; t[4] = m11[i]; t[5] = m02[i];
|
| 157 |
+
for (var r0 = 0; r0 < 6; r0++)
|
| 158 |
+
{
|
| 159 |
+
double acc = 0;
|
| 160 |
+
for (var c = 0; c < 6; c++) acc += inv[r0, c] * t[c];
|
| 161 |
+
r[r0] = acc;
|
| 162 |
+
}
|
| 163 |
+
c0[i] = (float)r[0];
|
| 164 |
+
bx[i] = (float)r[1];
|
| 165 |
+
by[i] = (float)r[2];
|
| 166 |
+
axx[i] = (float)r[3];
|
| 167 |
+
axy[i] = (float)r[4];
|
| 168 |
+
ayy[i] = (float)r[5];
|
| 169 |
+
}
|
| 170 |
+
return scratch;
|
| 171 |
+
},
|
| 172 |
+
_ => { });
|
| 173 |
+
|
| 174 |
+
return new Exp { Axx = axx, Axy = axy, Ayy = ayy, Bx = bx, By = by, C = c0, Width = w, Height = h };
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/// <summary>长度 2k+1 的归一化高斯核</summary>
|
| 178 |
+
private static double[] Gaussian1D(int k, double sigma)
|
| 179 |
+
{
|
| 180 |
+
var n = 2 * k + 1;
|
| 181 |
+
var g = new double[n];
|
| 182 |
+
double sum = 0;
|
| 183 |
+
for (var i = 0; i < n; i++)
|
| 184 |
+
{
|
| 185 |
+
var u = i - k;
|
| 186 |
+
g[i] = Math.Exp(-(u * u) / (2 * sigma * sigma));
|
| 187 |
+
sum += g[i];
|
| 188 |
+
}
|
| 189 |
+
for (var i = 0; i < n; i++) g[i] /= sum;
|
| 190 |
+
return g;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/// <summary>可分离相关:out(x,y) = ΣΣ gx(u)·gy(v)·(u/k)^px·(v/k)^py · src(x+u, y+v),越界按边界复制。
|
| 194 |
+
/// 坐标按半窗宽 k 归一化(Farnebäck 的做法):平坦区系数不会退化成极小值,
|
| 195 |
+
/// 后面 2×2 求解的条件数才稳定。</summary>
|
| 196 |
+
private static float[] Correlate2(float[] src, int w, int h, double[] g, double[] gy, int px, int py)
|
| 197 |
+
{
|
| 198 |
+
var k = g.Length / 2;
|
| 199 |
+
var tmp = new float[w * h];
|
| 200 |
+
var kx = new double[g.Length];
|
| 201 |
+
for (var i = 0; i < g.Length; i++) kx[i] = g[i] * Math.Pow((i - k) / (double)k, px);
|
| 202 |
+
|
| 203 |
+
Parallel.For(0, h, y =>
|
| 204 |
+
{
|
| 205 |
+
for (var x = 0; x < w; x++)
|
| 206 |
+
{
|
| 207 |
+
double acc = 0;
|
| 208 |
+
for (var u = -k; u <= k; u++)
|
| 209 |
+
{
|
| 210 |
+
var sx = Math.Clamp(x + u, 0, w - 1);
|
| 211 |
+
acc += kx[u + k] * src[y * w + sx];
|
| 212 |
+
}
|
| 213 |
+
tmp[y * w + x] = (float)acc;
|
| 214 |
+
}
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
var ky = new double[g.Length];
|
| 218 |
+
for (var i = 0; i < g.Length; i++) ky[i] = gy[i] * Math.Pow((i - k) / (double)k, py);
|
| 219 |
+
|
| 220 |
+
var dst = new float[w * h];
|
| 221 |
+
Parallel.For(0, h, y =>
|
| 222 |
+
{
|
| 223 |
+
for (var x = 0; x < w; x++)
|
| 224 |
+
{
|
| 225 |
+
double acc = 0;
|
| 226 |
+
for (var v = -k; v <= k; v++)
|
| 227 |
+
{
|
| 228 |
+
var sy = Math.Clamp(y + v, 0, h - 1);
|
| 229 |
+
acc += ky[v + k] * tmp[sy * w + x];
|
| 230 |
+
}
|
| 231 |
+
dst[y * w + x] = (float)acc;
|
| 232 |
+
}
|
| 233 |
+
});
|
| 234 |
+
return dst;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/// <summary>M = Σ W·φφᵀ(φ = [1,ũ,ṽ,ũ²,ũṽ,ṽ²],ũ = u/k)的逆,用高斯-约当求逆。</summary>
|
| 238 |
+
private static double[,] MomentInverse(int k, double[] g)
|
| 239 |
+
{
|
| 240 |
+
var m = new double[6, 6];
|
| 241 |
+
for (var u = -k; u <= k; u++)
|
| 242 |
+
for (var v = -k; v <= k; v++)
|
| 243 |
+
{
|
| 244 |
+
var weight = g[u + k] * g[v + k];
|
| 245 |
+
var un = u / (double)k;
|
| 246 |
+
var vn = v / (double)k;
|
| 247 |
+
var phi = new double[] { 1, un, vn, un * un, un * vn, vn * vn };
|
| 248 |
+
for (var i = 0; i < 6; i++)
|
| 249 |
+
for (var j = 0; j < 6; j++)
|
| 250 |
+
m[i, j] += weight * phi[i] * phi[j];
|
| 251 |
+
}
|
| 252 |
+
return Invert6(m);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
private static double[,] Invert6(double[,] a)
|
| 256 |
+
{
|
| 257 |
+
var n = 6;
|
| 258 |
+
var m = new double[n, 2 * n];
|
| 259 |
+
for (var i = 0; i < n; i++)
|
| 260 |
+
{
|
| 261 |
+
for (var j = 0; j < n; j++) m[i, j] = a[i, j];
|
| 262 |
+
m[i, n + i] = 1;
|
| 263 |
+
}
|
| 264 |
+
for (var col = 0; col < n; col++)
|
| 265 |
+
{
|
| 266 |
+
var pivot = col;
|
| 267 |
+
for (var r = col + 1; r < n; r++)
|
| 268 |
+
if (Math.Abs(m[r, col]) > Math.Abs(m[pivot, col])) pivot = r;
|
| 269 |
+
if (Math.Abs(m[pivot, col]) < 1e-12) continue;
|
| 270 |
+
if (pivot != col)
|
| 271 |
+
for (var j = 0; j < 2 * n; j++) (m[col, j], m[pivot, j]) = (m[pivot, j], m[col, j]);
|
| 272 |
+
var d = m[col, col];
|
| 273 |
+
for (var j = 0; j < 2 * n; j++) m[col, j] /= d;
|
| 274 |
+
for (var r = 0; r < n; r++)
|
| 275 |
+
{
|
| 276 |
+
if (r == col) continue;
|
| 277 |
+
var f = m[r, col];
|
| 278 |
+
if (f == 0) continue;
|
| 279 |
+
for (var j = 0; j < 2 * n; j++) m[r, j] -= f * m[col, j];
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
var inv = new double[n, n];
|
| 283 |
+
for (var i = 0; i < n; i++)
|
| 284 |
+
for (var j = 0; j < n; j++) inv[i, j] = m[i, n + j];
|
| 285 |
+
return inv;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
// ===================== 位移求解 =====================
|
| 289 |
+
|
| 290 |
+
/// <summary>一轮迭代:warp 第一帧展开场 → 逐像素 A、Δb → 窗口累加 → 解 2×2 → 位移累加</summary>
|
| 291 |
+
private static void UpdateFlow(Exp e1, Exp e2, float[] fx, float[] fy, int w, int h, Options opt)
|
| 292 |
+
{
|
| 293 |
+
var n = w * h;
|
| 294 |
+
var g11 = new double[n];
|
| 295 |
+
var g12 = new double[n];
|
| 296 |
+
var g22 = new double[n];
|
| 297 |
+
var h1 = new double[n];
|
| 298 |
+
var h2 = new double[n];
|
| 299 |
+
|
| 300 |
+
Parallel.For(0, h, y =>
|
| 301 |
+
{
|
| 302 |
+
for (var x = 0; x < w; x++)
|
| 303 |
+
{
|
| 304 |
+
var i = y * w + x;
|
| 305 |
+
// 第 2 帧位置 x 的内容来自第 1 帧的 x - d,所以第 1 帧展开场要采样在 (x - fx, y - fy)
|
| 306 |
+
var x1 = x - fx[i];
|
| 307 |
+
var y1 = y - fy[i];
|
| 308 |
+
|
| 309 |
+
// 第二帧系数(原地)
|
| 310 |
+
var a2xx = e2.Axx[i]; var a2xy = e2.Axy[i]; var a2yy = e2.Ayy[i];
|
| 311 |
+
var b2x = e2.Bx[i]; var b2y = e2.By[i];
|
| 312 |
+
|
| 313 |
+
// 第一帧系数按其位移 warp 过来(双线性采样)
|
| 314 |
+
var a1xx = SampleBilinear(e1.Axx, w, h, x1, y1);
|
| 315 |
+
var a1xy = SampleBilinear(e1.Axy, w, h, x1, y1);
|
| 316 |
+
var a1yy = SampleBilinear(e1.Ayy, w, h, x1, y1);
|
| 317 |
+
var b1x = SampleBilinear(e1.Bx, w, h, x1, y1);
|
| 318 |
+
var b1y = SampleBilinear(e1.By, w, h, x1, y1);
|
| 319 |
+
|
| 320 |
+
var axx = 0.5 * (a1xx + a2xx);
|
| 321 |
+
var axy = 0.5 * (a1xy + a2xy);
|
| 322 |
+
var ayy = 0.5 * (a1yy + a2yy);
|
| 323 |
+
var rx = b2x - b1x;
|
| 324 |
+
var ry = b2y - b1y;
|
| 325 |
+
|
| 326 |
+
// 加权最小二乘的窗口累加量:Σ(AᵀA) 与 Σ(AᵀΔb)
|
| 327 |
+
g11[i] = axx * axx + axy * axy;
|
| 328 |
+
g12[i] = (axx + ayy) * axy;
|
| 329 |
+
g22[i] = axy * axy + ayy * ayy;
|
| 330 |
+
h1[i] = axx * rx + axy * ry;
|
| 331 |
+
h2[i] = axy * rx + ayy * ry;
|
| 332 |
+
}
|
| 333 |
+
});
|
| 334 |
+
|
| 335 |
+
var k = Math.Max(1, opt.PolyN / 2); // 归一化坐标 → 像素的换算因子
|
| 336 |
+
var win = Math.Max(3, opt.WinSize);
|
| 337 |
+
var s11 = BoxSum(g11, w, h, win);
|
| 338 |
+
var s12 = BoxSum(g12, w, h, win);
|
| 339 |
+
var s22 = BoxSum(g22, w, h, win);
|
| 340 |
+
var t1 = BoxSum(h1, w, h, win);
|
| 341 |
+
var t2 = BoxSum(h2, w, h, win);
|
| 342 |
+
|
| 343 |
+
const double incLimit = 16.0; // 单步位移上限(像素),防病态区数值爆炸
|
| 344 |
+
Parallel.For(0, h, y =>
|
| 345 |
+
{
|
| 346 |
+
for (var x = 0; x < w; x++)
|
| 347 |
+
{
|
| 348 |
+
var i = y * w + x;
|
| 349 |
+
var a11 = s11[i];
|
| 350 |
+
var a12 = s12[i];
|
| 351 |
+
var a22 = s22[i];
|
| 352 |
+
var det = a11 * a22 - a12 * a12;
|
| 353 |
+
|
| 354 |
+
double dx = 0, dy = 0;
|
| 355 |
+
// 条件数守卫:纹理太弱(两特征值都小)或退化(一条边方向不可观测)时不动
|
| 356 |
+
if (det > 1e-6 && det > 1e-3 * a11 * a22)
|
| 357 |
+
{
|
| 358 |
+
// Δb = -2A(d-f) → d-f = -½(AᵀA)⁻¹AᵀΔb,再乘 k 换回像素
|
| 359 |
+
var tx = -0.5 * k * (a22 * t1[i] - a12 * t2[i]) / det;
|
| 360 |
+
var ty = -0.5 * k * (a11 * t2[i] - a12 * t1[i]) / det;
|
| 361 |
+
var mag = Math.Sqrt(tx * tx + ty * ty);
|
| 362 |
+
if (mag > incLimit)
|
| 363 |
+
{
|
| 364 |
+
tx *= incLimit / mag;
|
| 365 |
+
ty *= incLimit / mag;
|
| 366 |
+
}
|
| 367 |
+
dx = tx;
|
| 368 |
+
dy = ty;
|
| 369 |
+
}
|
| 370 |
+
fx[i] += (float)dx;
|
| 371 |
+
fy[i] += (float)dy;
|
| 372 |
+
}
|
| 373 |
+
});
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
/// <summary>窗口积分(积分图)</summary>
|
| 377 |
+
private static double[] BoxSum(double[] src, int w, int h, int win)
|
| 378 |
+
{
|
| 379 |
+
var r = win / 2;
|
| 380 |
+
var sat = new double[(w + 1) * (h + 1)];
|
| 381 |
+
for (var y = 0; y < h; y++)
|
| 382 |
+
{
|
| 383 |
+
double rowSum = 0;
|
| 384 |
+
for (var x = 0; x < w; x++)
|
| 385 |
+
{
|
| 386 |
+
rowSum += src[y * w + x];
|
| 387 |
+
sat[(y + 1) * (w + 1) + (x + 1)] = sat[y * (w + 1) + (x + 1)] + rowSum;
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
var dst = new double[w * h];
|
| 391 |
+
Parallel.For(0, h, y =>
|
| 392 |
+
{
|
| 393 |
+
var y0 = Math.Clamp(y - r, 0, h - 1);
|
| 394 |
+
var y1 = Math.Clamp(y + r, 0, h - 1);
|
| 395 |
+
for (var x = 0; x < w; x++)
|
| 396 |
+
{
|
| 397 |
+
var x0 = Math.Clamp(x - r, 0, w - 1);
|
| 398 |
+
var x1 = Math.Clamp(x + r, 0, w - 1);
|
| 399 |
+
var a = sat[y0 * (w + 1) + x0];
|
| 400 |
+
var b = sat[y0 * (w + 1) + x1 + 1];
|
| 401 |
+
var c = sat[(y1 + 1) * (w + 1) + x0];
|
| 402 |
+
var d = sat[(y1 + 1) * (w + 1) + x1 + 1];
|
| 403 |
+
dst[y * w + x] = d - b - c + a;
|
| 404 |
+
}
|
| 405 |
+
});
|
| 406 |
+
return dst;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// ===================== 图像工具 =====================
|
| 410 |
+
|
| 411 |
+
private static float[] ToGray(byte[] bgra, int w, int h)
|
| 412 |
+
{
|
| 413 |
+
var n = w * h;
|
| 414 |
+
var g = new float[n];
|
| 415 |
+
for (int i = 0, p = 0; i < n; i++, p += 4)
|
| 416 |
+
g[i] = (bgra[p + 2] * 299 + bgra[p + 1] * 587 + bgra[p] * 114) / 1000f;
|
| 417 |
+
return g;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
/// <summary>双线性缩放(像素中心对齐)</summary>
|
| 421 |
+
private static float[] Resize(float[] src, int sw, int sh, int dw, int dh)
|
| 422 |
+
{
|
| 423 |
+
var dst = new float[dw * dh];
|
| 424 |
+
var kx = sw / (double)dw;
|
| 425 |
+
var ky = sh / (double)dh;
|
| 426 |
+
Parallel.For(0, dh, y =>
|
| 427 |
+
{
|
| 428 |
+
var sy = (y + 0.5) * ky - 0.5;
|
| 429 |
+
for (var x = 0; x < dw; x++)
|
| 430 |
+
dst[y * dw + x] = (float)SampleBilinear(src, sw, sh, (x + 0.5) * kx - 0.5, sy);
|
| 431 |
+
});
|
| 432 |
+
return dst;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
private static double SampleBilinear(float[] src, int w, int h, double x, double y)
|
| 436 |
+
{
|
| 437 |
+
if (w <= 0 || h <= 0) return 0;
|
| 438 |
+
var cx = Math.Clamp(x, 0, w - 1);
|
| 439 |
+
var cy = Math.Clamp(y, 0, h - 1);
|
| 440 |
+
var x0 = (int)Math.Floor(cx);
|
| 441 |
+
var y0 = (int)Math.Floor(cy);
|
| 442 |
+
var x1 = Math.Min(x0 + 1, w - 1);
|
| 443 |
+
var y1 = Math.Min(y0 + 1, h - 1);
|
| 444 |
+
var tx = cx - x0;
|
| 445 |
+
var ty = cy - y0;
|
| 446 |
+
var v00 = src[y0 * w + x0];
|
| 447 |
+
var v10 = src[y0 * w + x1];
|
| 448 |
+
var v01 = src[y1 * w + x0];
|
| 449 |
+
var v11 = src[y1 * w + x1];
|
| 450 |
+
return (v00 * (1 - tx) + v10 * tx) * (1 - ty) + (v01 * (1 - tx) + v11 * tx) * ty;
|
| 451 |
+
}
|
| 452 |
+
}
|
code/algorithm/FrameDedupService.cs
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.IO;
|
| 2 |
+
using System.Runtime.InteropServices.WindowsRuntime;
|
| 3 |
+
using Windows.Graphics.Imaging;
|
| 4 |
+
|
| 5 |
+
namespace Easy4K.SFDP;
|
| 6 |
+
|
| 7 |
+
/// <summary>相邻帧去重(论文「四阶级联判决」)。
|
| 8 |
+
/// 第一阶像素差粗筛、第二阶 dHash 细筛、第三阶 QR 分解精判、第四阶 Farnebäck 光流终判。
|
| 9 |
+
/// 性能模式 = 前两阶;完美模式 = 四阶全开。
|
| 10 |
+
/// 本类只做「判决」并给出保留/回填索引表,**不修改任何文件**;剔除与回填由流水线负责
|
| 11 |
+
/// (判为重复的帧不是真删除,只是不进超分/补帧,合并前按 ExpandMap 原样补回,
|
| 12 |
+
/// 因此成品时长、帧率与音频保持不变)。</summary>
|
| 13 |
+
public static class FrameDedupService
|
| 14 |
+
{
|
| 15 |
+
/// <summary>单像素差异阈值(论文建议 10):超过该值才算"这个像素变了"</summary>
|
| 16 |
+
private const int PixelThreshold = 10;
|
| 17 |
+
|
| 18 |
+
/// <summary>细块边长 = min(宽,高) / 该值。1080p 下约 33×33 像素,约 1900 块。</summary>
|
| 19 |
+
private const int BlockDiv = 32;
|
| 20 |
+
|
| 21 |
+
/// <summary>粗块 = 该值 × 该值 个细块(1080p 下约 132×132 像素)</summary>
|
| 22 |
+
private const int CoarseGroup = 4;
|
| 23 |
+
|
| 24 |
+
/// <summary>判决参数。阈值取自论文建议值;完美模式整体收紧一档以压低误删。</summary>
|
| 25 |
+
public sealed record Options(
|
| 26 |
+
string Mode,
|
| 27 |
+
double MadThreshold,
|
| 28 |
+
double RatioThreshold,
|
| 29 |
+
int HashThreshold,
|
| 30 |
+
int MinRunLength,
|
| 31 |
+
double QrThreshold,
|
| 32 |
+
double FlowThreshold,
|
| 33 |
+
double FlowVarThreshold,
|
| 34 |
+
double BlockMadThreshold,
|
| 35 |
+
double CoarseBlockMadThreshold)
|
| 36 |
+
{
|
| 37 |
+
/// <summary>细块内"变化像素占比"阈值(默认 2%)。补的是"块内平均差"的盲区:
|
| 38 |
+
/// 细块里若只有几十个像素发生了真实运动(幅度十几个灰阶),平均到整块(1089 像素)会被稀释到
|
| 39 |
+
/// τ_block=2.0 以下 —— 平均差看不见、dHash 更看不见,于是被判成重复帧删掉。
|
| 40 |
+
/// 占比判据只做加法:它只会把"本来要判重复"的帧对改判为不同,方向上只会更保守(少删、不误删)。</summary>
|
| 41 |
+
public double BlockChangedRatio { get; init; } = 0.02;
|
| 42 |
+
|
| 43 |
+
/// <summary>粗块组内"变化像素占比"阈值(默认 0.5%):兜大范围但幅度小的变化。</summary>
|
| 44 |
+
public double CoarseChangedRatio { get; init; } = 0.005;
|
| 45 |
+
|
| 46 |
+
/// <summary>按模式名取参数(performance / uhd)。
|
| 47 |
+
/// 光流阈值取论文建议值 τ_flow=0.5、τ_var=0.1;QR 阈值因归一化修正后重新标定为 1.0 灰阶
|
| 48 |
+
/// (论文的 0.05 对应的是 D_Mink/(K·B) 那套归一化,与这里量纲不同,不能直接照搬)。
|
| 49 |
+
/// 标定依据:1080p 动画 MV 3600 帧、566 个候选帧对,QR 距离分布 P25 0.016 / P50 0.532 /
|
| 50 |
+
/// P75 1.479 / P90 2.136 / max 4.53;阈值从 0.5 扫到 5.0 灰阶,最终判重帧数恒为 102 帧不变,
|
| 51 |
+
/// 差别只在送进光流的对数(τ=1.0 可省掉约 1/3 光流调用),所以取下限附近、按"只挡明显不同的帧对"取值。
|
| 52 |
+
///
|
| 53 |
+
/// 分块局部阈值的来由(这是第 1 阶唯一的可靠判据,见 Judge 注释):
|
| 54 |
+
/// 在 1080p 静止+编码噪声素材(752 帧)上实测,
|
| 55 |
+
/// "全局 MAD ≤ 0.1" 的 209 对里有 23.4% 存在块内平均差 ≥ 2.0(最高 24、该块 59% 像素都变了)的真实局部运动;
|
| 56 |
+
/// 而真正雷同的帧对,33×33 块内平均差中位仅 0.70。故细块取 2.0,分离度约 3 倍。
|
| 57 |
+
/// 粗块 1.0 是兜"大范围但幅度小的变化"(渐变/慢摇),实测只在 3/751 对上额外生效,几乎无副作用。</summary>
|
| 58 |
+
public static Options FromMode(string? mode) => mode == "uhd"
|
| 59 |
+
? new Options("uhd", 1.0, 0.006, 4, 3, 1.0, 0.5, 0.1, 2.0, 1.0)
|
| 60 |
+
// 性能档的 dHash 阈值从 5 收到 4:64bit 哈希允许 5 位不同偏宽(默认档又是性能档),
|
| 61 |
+
// 只差几笔的相邻帧也可能被一并判成重复,收紧到与完美档同一水平。
|
| 62 |
+
: new Options("performance", 2.0, 0.01, 4, 3, 1.0, 0.5, 0.1, 2.0, 1.0);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/// <summary>判决所处阶(用于统计各阶淘汰量与进度文本)</summary>
|
| 66 |
+
public enum Stage
|
| 67 |
+
{
|
| 68 |
+
/// <summary>首帧或无对比对象</summary>
|
| 69 |
+
FirstFrame = 0,
|
| 70 |
+
/// <summary>第一阶像素差淘汰</summary>
|
| 71 |
+
PixelDiff = 1,
|
| 72 |
+
/// <summary>第二阶 dHash 淘汰</summary>
|
| 73 |
+
Hash = 2,
|
| 74 |
+
/// <summary>第三阶 QR 淘汰</summary>
|
| 75 |
+
Qr = 3,
|
| 76 |
+
/// <summary>第四阶光流淘汰</summary>
|
| 77 |
+
Flow = 4,
|
| 78 |
+
/// <summary>四阶全过 → 判为重复</summary>
|
| 79 |
+
Duplicate = 5,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/// <summary>阶段短名(进度文本 / 日志用)</summary>
|
| 83 |
+
public static string StageName(Stage s) => s switch
|
| 84 |
+
{
|
| 85 |
+
Stage.FirstFrame => "首帧",
|
| 86 |
+
Stage.PixelDiff => "像素差",
|
| 87 |
+
Stage.Hash => "dHash",
|
| 88 |
+
Stage.Qr => "QR分解",
|
| 89 |
+
Stage.Flow => "光流",
|
| 90 |
+
_ => "判重",
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
/// <summary>单帧判决结果(Index 从 1 开始,与 ffmpeg 输出的 00000001.png 对齐)</summary>
|
| 94 |
+
public sealed record FrameDecision(
|
| 95 |
+
int Index, bool Duplicate, double Mad, int Hamming,
|
| 96 |
+
double Qr, double MeanFlow, double VarFlow, Stage Decided);
|
| 97 |
+
|
| 98 |
+
/// <summary>整段分析结果</summary>
|
| 99 |
+
public sealed record Result(IReadOnlyList<FrameDecision> Decisions, int Total, int DuplicateCount)
|
| 100 |
+
{
|
| 101 |
+
/// <summary>实际需要送去超分/补帧的帧数</summary>
|
| 102 |
+
public int KeptCount => Total - DuplicateCount;
|
| 103 |
+
|
| 104 |
+
/// <summary>去重率(0~1)</summary>
|
| 105 |
+
public double DedupRate => Total <= 0 ? 0 : (double)DuplicateCount / Total;
|
| 106 |
+
|
| 107 |
+
/// <summary>回填表:展开后的第 k 帧(1 基)对应原始第几帧(1 基)。长度 = Total</summary>
|
| 108 |
+
public IReadOnlyList<int> ExpandMap { get; init; } = Array.Empty<int>();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/// <summary>判决相邻两帧是否重复。a/b 为 BGRA8 像素、尺寸相同。</summary>
|
| 112 |
+
public static (bool Duplicate, double Mad, int Hamming, double Qr, double MeanFlow, double VarFlow, Stage Decided) Judge(
|
| 113 |
+
byte[] a, byte[] b, int width, int height, Options opt)
|
| 114 |
+
{
|
| 115 |
+
var pixels = width * height;
|
| 116 |
+
if (pixels <= 0 || a.Length < pixels * 4 || b.Length < pixels * 4)
|
| 117 |
+
return (false, 0, 64, double.MaxValue, 0, 0, Stage.PixelDiff);
|
| 118 |
+
|
| 119 |
+
// ============ 第 1 阶:像素差粗筛(全局 + 两级分块局部) ============
|
| 120 |
+
// 全局平均差 MAD 与变化像素比例只能反映"整幅画面都变了":1080p 里一个只占画面 0.05% 的元素在动,
|
| 121 |
+
// 全局 MAD 只有 0.02~0.1、变化像素占比不到 0.01%,会被彻底平均掉;8×8 的 dHash 更是完全无感。
|
| 122 |
+
// 实测(1080p 静止+噪声素材):被判"全局几乎无变化"的帧对里,23.4% 其实存在块内平均差高达 24 的
|
| 123 |
+
// 真实局部运动;而真正雷同的帧对,块内平均差中位仅 0.70。所以这一阶必须按块看局部:
|
| 124 |
+
// 细块(≈1/32 边长):任何一块的平均差 ≥ τ_block → 有小范围真实运动,判为不同
|
| 125 |
+
// 粗块(4×4 细块):任何一组的平均差 ≥ τ_coarse → 大范围小幅变化(渐变/慢摇),判为不同
|
| 126 |
+
// 只多两趟很小的数组遍历,不多扫一遍像素。
|
| 127 |
+
var bs = Math.Max(8, Math.Min(width, height) / BlockDiv);
|
| 128 |
+
var cols = (width + bs - 1) / bs;
|
| 129 |
+
var rows = (height + bs - 1) / bs;
|
| 130 |
+
var blockSum = new double[rows * cols];
|
| 131 |
+
var blockPixels = new int[rows * cols];
|
| 132 |
+
var blockChanged = new int[rows * cols]; // 块内"变化像素"数(d > τ_pixel),用于占比判据
|
| 133 |
+
|
| 134 |
+
double sum = 0;
|
| 135 |
+
var changed = 0;
|
| 136 |
+
for (var by = 0; by < rows; by++)
|
| 137 |
+
{
|
| 138 |
+
var y0 = by * bs;
|
| 139 |
+
var y1 = Math.Min(y0 + bs, height);
|
| 140 |
+
for (var bx = 0; bx < cols; bx++)
|
| 141 |
+
{
|
| 142 |
+
var x0 = bx * bs;
|
| 143 |
+
var x1 = Math.Min(x0 + bs, width);
|
| 144 |
+
double bsum = 0;
|
| 145 |
+
var bn = 0;
|
| 146 |
+
var bchanged = 0;
|
| 147 |
+
for (var y = y0; y < y1; y++)
|
| 148 |
+
{
|
| 149 |
+
var rowBase = y * width;
|
| 150 |
+
for (var x = x0; x < x1; x++)
|
| 151 |
+
{
|
| 152 |
+
var p = (rowBase + x) * 4;
|
| 153 |
+
// BGRA 布局:p+2=R, p+1=G, p=B(ITU-R BT.601 灰度权重)
|
| 154 |
+
var ga = (a[p + 2] * 299 + a[p + 1] * 587 + a[p] * 114) / 1000;
|
| 155 |
+
var gb = (b[p + 2] * 299 + b[p + 1] * 587 + b[p] * 114) / 1000;
|
| 156 |
+
var d = Math.Abs(ga - gb);
|
| 157 |
+
sum += d;
|
| 158 |
+
if (d > PixelThreshold) { changed++; bchanged++; }
|
| 159 |
+
bsum += d;
|
| 160 |
+
bn++;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
blockSum[by * cols + bx] = bsum;
|
| 164 |
+
blockPixels[by * cols + bx] = bn;
|
| 165 |
+
blockChanged[by * cols + bx] = bchanged;
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
var mad = sum / pixels;
|
| 170 |
+
var ratio = (double)changed / pixels;
|
| 171 |
+
if (mad > opt.MadThreshold || ratio > opt.RatioThreshold)
|
| 172 |
+
return (false, mad, 64, double.MaxValue, 0, 0, Stage.PixelDiff); // 整幅都变了,直接保留
|
| 173 |
+
|
| 174 |
+
// 细块:小范围真实运动
|
| 175 |
+
for (var bi = 0; bi < blockSum.Length; bi++)
|
| 176 |
+
if (blockPixels[bi] > 0 && blockSum[bi] / blockPixels[bi] >= opt.BlockMadThreshold)
|
| 177 |
+
return (false, mad, 64, double.MaxValue, 0, 0, Stage.PixelDiff);
|
| 178 |
+
|
| 179 |
+
// 细块:小面积运动 —— 块内平均差会被大块底噪稀释,改用"变化像素占比"兜住
|
| 180 |
+
for (var bi = 0; bi < blockChanged.Length; bi++)
|
| 181 |
+
if (blockPixels[bi] > 0 && blockChanged[bi] / (double)blockPixels[bi] >= opt.BlockChangedRatio)
|
| 182 |
+
return (false, mad, 64, double.MaxValue, 0, 0, Stage.PixelDiff);
|
| 183 |
+
|
| 184 |
+
// 粗块:大范围小幅变化
|
| 185 |
+
for (var by = 0; by + CoarseGroup <= rows; by += CoarseGroup)
|
| 186 |
+
for (var bx = 0; bx + CoarseGroup <= cols; bx += CoarseGroup)
|
| 187 |
+
{
|
| 188 |
+
double cs = 0;
|
| 189 |
+
var cn = 0;
|
| 190 |
+
var cchanged = 0;
|
| 191 |
+
for (var y = by; y < by + CoarseGroup; y++)
|
| 192 |
+
for (var x = bx; x < bx + CoarseGroup; x++)
|
| 193 |
+
{
|
| 194 |
+
var bi = y * cols + x;
|
| 195 |
+
cs += blockSum[bi];
|
| 196 |
+
cn += blockPixels[bi];
|
| 197 |
+
cchanged += blockChanged[bi];
|
| 198 |
+
}
|
| 199 |
+
if (cn > 0 && cs / cn >= opt.CoarseBlockMadThreshold)
|
| 200 |
+
return (false, mad, 64, double.MaxValue, 0, 0, Stage.PixelDiff);
|
| 201 |
+
if (cn > 0 && cchanged / (double)cn >= opt.CoarseChangedRatio)
|
| 202 |
+
return (false, mad, 64, double.MaxValue, 0, 0, Stage.PixelDiff);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// ============ 第 2 阶:dHash 细筛(9×8 灰度水平差分 → 64bit → 汉明距离) ============
|
| 206 |
+
var hamming = HammingDistance(DHash(a, width, height), DHash(b, width, height));
|
| 207 |
+
if (hamming > opt.HashThreshold)
|
| 208 |
+
return (false, mad, hamming, double.MaxValue, 0, 0, Stage.Hash);
|
| 209 |
+
|
| 210 |
+
// ============ 性能模式到此结束 ============
|
| 211 |
+
if (opt.Mode != "uhd")
|
| 212 |
+
return (true, mad, hamming, 0, 0, 0, Stage.Duplicate);
|
| 213 |
+
|
| 214 |
+
// ============ 第 3 阶:QR 分解精判(8×8 分块 → diag(R) 特征 → Minkowski 距离) ============
|
| 215 |
+
var qr = QrDistance(a, b, width, height);
|
| 216 |
+
if (qr > opt.QrThreshold)
|
| 217 |
+
return (false, mad, hamming, qr, 0, 0, Stage.Qr);
|
| 218 |
+
|
| 219 |
+
// ============ 第 4 阶:Farnebäck 光流终判(MeanFlow / VarFlow 双阈值) ============
|
| 220 |
+
var flow = FarnebackFlow.Compute(a, b, width, height, new FarnebackFlow.Options());
|
| 221 |
+
if (flow.MeanFlow > opt.FlowThreshold || flow.VarFlow > opt.FlowVarThreshold)
|
| 222 |
+
return (false, mad, hamming, qr, flow.MeanFlow, flow.VarFlow, Stage.Flow);
|
| 223 |
+
|
| 224 |
+
return (true, mad, hamming, qr, flow.MeanFlow, flow.VarFlow, Stage.Duplicate);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/// <summary>分析进度:当前阶段 + 已处理/总数 + 已判重帧数 + 三联预览所需的三张帧:
|
| 228 |
+
/// ① 筛选帧 = 当前正在判决的帧(右,逐帧前移);② 对比帧 = 与它作比较的那一帧,即上一帧(中,同样前移);
|
| 229 |
+
/// ③ 判决帧 = 最近一次被判为重复的帧(左,只在判出重复时更换)。</summary>
|
| 230 |
+
public readonly record struct AnalysisProgress(
|
| 231 |
+
string Phase, int Done, int Total, int DuplicateCount,
|
| 232 |
+
string CurrentFramePath, int CurrentIndex,
|
| 233 |
+
string CompareFramePath, int CompareIndex,
|
| 234 |
+
string VerdictFramePath, int VerdictIndex);
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
/// <summary>扫描帧目录做判决,返回结果与回填表(不改动文件)。
|
| 238 |
+
/// 判决计算(第 3 阶 QR 分解、第 4 阶 Farnebäck 稠密光流)是纯 CPU 密集运算,
|
| 239 |
+
/// 必须整段放到后台线程执行:否则会在 UI 线程上逐帧计算,界面直接卡死。
|
| 240 |
+
/// 进度回调内部会把进度调度回 UI 线程,因此从后台线程调用是安全的。
|
| 241 |
+
/// applySmoothing=false 时跳过块内时序平滑(由调用方掌握全局后统一做,涡轮模式按块流式判决时用)。</summary>
|
| 242 |
+
public static Task<Result> AnalyzeAsync(
|
| 243 |
+
string framesDir, Options opt, Action<AnalysisProgress>? progress, CancellationToken ct,
|
| 244 |
+
bool applySmoothing = true)
|
| 245 |
+
{
|
| 246 |
+
var files = Directory.GetFiles(framesDir, "*.png");
|
| 247 |
+
Array.Sort(files, StringComparer.OrdinalIgnoreCase);
|
| 248 |
+
return AnalyzeAsync(files, opt, progress, ct, applySmoothing);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/// <summary>按给定帧清单做判决(清单顺序即处理顺序,不改动文件)。
|
| 252 |
+
/// 涡轮模式用这个重载按块流式判决:块内清单前面加一张上一块的末帧当"对比帧",
|
| 253 |
+
/// 判决结果与整段一次跑完全一致(判决只看相邻两帧),不需要为每块复制一份帧文件。</summary>
|
| 254 |
+
public static Task<Result> AnalyzeAsync(
|
| 255 |
+
IReadOnlyList<string> files, Options opt, Action<AnalysisProgress>? progress, CancellationToken ct,
|
| 256 |
+
bool applySmoothing = true)
|
| 257 |
+
=> Task.Run(() => AnalyzeCoreAsync(files, opt, progress, ct, applySmoothing), ct);
|
| 258 |
+
|
| 259 |
+
private static async Task<Result> AnalyzeCoreAsync(
|
| 260 |
+
IReadOnlyList<string> files, Options opt, Action<AnalysisProgress>? progress, CancellationToken ct,
|
| 261 |
+
bool applySmoothing)
|
| 262 |
+
{
|
| 263 |
+
var decisions = new List<FrameDecision>(files.Count);
|
| 264 |
+
byte[]? prevPixels = null;
|
| 265 |
+
var prevW = 0;
|
| 266 |
+
var prevH = 0;
|
| 267 |
+
var dupSoFar = 0;
|
| 268 |
+
var batchDeepest = Stage.FirstFrame; // 本批(16 帧)实际跑到的最深判决阶,用于进度文本
|
| 269 |
+
var prevPath = ""; // 上一帧路径 = 预览框中间的「对比帧」
|
| 270 |
+
var markedPath = ""; // 最近被判为重复的帧 = 预览框左侧的「判决帧」
|
| 271 |
+
var markedIndex = 0; // 它的原始帧号(1 基)
|
| 272 |
+
for (var i = 0; i < files.Count; i++)
|
| 273 |
+
{
|
| 274 |
+
ct.ThrowIfCancellationRequested();
|
| 275 |
+
var (pixels, w, h) = await LoadBgraAsync(files[i]);
|
| 276 |
+
|
| 277 |
+
// 首帧没有可比对象,永远保留
|
| 278 |
+
if (prevPixels is null || w != prevW || h != prevH)
|
| 279 |
+
{
|
| 280 |
+
decisions.Add(new FrameDecision(i + 1, false, 0, 0, 0, 0, 0, Stage.FirstFrame));
|
| 281 |
+
}
|
| 282 |
+
else
|
| 283 |
+
{
|
| 284 |
+
var (dup, mad, hamming, qr, meanFlow, varFlow, stage) = Judge(prevPixels, pixels, w, h, opt);
|
| 285 |
+
decisions.Add(new FrameDecision(i + 1, dup, mad, hamming, qr, meanFlow, varFlow, stage));
|
| 286 |
+
if (dup)
|
| 287 |
+
{
|
| 288 |
+
dupSoFar++;
|
| 289 |
+
markedPath = files[i]; // 本帧与对比帧重复 → 更新左侧「判决帧」为这张最新判出的
|
| 290 |
+
markedIndex = i + 1;
|
| 291 |
+
}
|
| 292 |
+
// 判为重复时"实际跑到的阶"是模式决定的最深一阶(性能=dHash,完美=光流)
|
| 293 |
+
var ran = stage == Stage.Duplicate ? (opt.Mode == "uhd" ? Stage.Flow : Stage.Hash) : stage;
|
| 294 |
+
if (ran > batchDeepest) batchDeepest = ran;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
prevPixels = pixels;
|
| 298 |
+
prevW = w;
|
| 299 |
+
prevH = h;
|
| 300 |
+
|
| 301 |
+
if (i % 16 == 0 || i == files.Count - 1)
|
| 302 |
+
{
|
| 303 |
+
progress?.Invoke(new AnalysisProgress($"判决阶段({StageName(batchDeepest)})",
|
| 304 |
+
i + 1, files.Count, dupSoFar,
|
| 305 |
+
files[i], i + 1, // 筛选帧(右):当前正在判决的帧
|
| 306 |
+
prevPath, i, // 对比帧(中):与它作比较的上一帧
|
| 307 |
+
markedPath, markedIndex)); // 判决帧(左):最近被判为重复的帧
|
| 308 |
+
batchDeepest = Stage.FirstFrame;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
prevPath = files[i];
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// 时序平滑:孤立重复(连续长度 < MinRunLength)撤销删除,避免动画刻意的 1 帧顿帧被吃掉
|
| 315 |
+
var lastFrame = files.Count > 0 ? files[files.Count - 1] : "";
|
| 316 |
+
var lastIndex = Math.Max(0, files.Count - 1);
|
| 317 |
+
progress?.Invoke(new AnalysisProgress("时序平滑", files.Count, files.Count, dupSoFar,
|
| 318 |
+
lastFrame, files.Count, prevPath, lastIndex, markedPath, markedIndex));
|
| 319 |
+
if (applySmoothing) SmoothIsolatedRuns(decisions, opt.MinRunLength);
|
| 320 |
+
|
| 321 |
+
var dupCount = decisions.Count(d => d.Duplicate);
|
| 322 |
+
progress?.Invoke(new AnalysisProgress("时序平滑", files.Count, files.Count, dupCount,
|
| 323 |
+
lastFrame, files.Count, prevPath, lastIndex, markedPath, markedIndex));
|
| 324 |
+
var expand = new List<int>(decisions.Count);
|
| 325 |
+
for (var i = 0; i < decisions.Count; i++)
|
| 326 |
+
{
|
| 327 |
+
// 重复帧在展开时复用上一帧的源帧号(即"回填")
|
| 328 |
+
expand.Add(decisions[i].Duplicate && i > 0 ? expand[i - 1] : i + 1);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
return new Result(decisions, decisions.Count, dupCount) { ExpandMap = expand };
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
/// <summary>时序平滑:把长度不足 minRun 的连续"重复段"整段改判为保留。
|
| 335 |
+
/// public 供涡轮模式使用:它按块流式判决,块边界处的重复段要等看到下一块才知道真实长度,
|
| 336 |
+
/// 所以自己拿全局判决序列统一跑这一趟。</summary>
|
| 337 |
+
public static void SmoothIsolatedRuns(List<FrameDecision> decisions, int minRun)
|
| 338 |
+
{
|
| 339 |
+
var i = 0;
|
| 340 |
+
while (i < decisions.Count)
|
| 341 |
+
{
|
| 342 |
+
if (!decisions[i].Duplicate) { i++; continue; }
|
| 343 |
+
|
| 344 |
+
var start = i;
|
| 345 |
+
while (i < decisions.Count && decisions[i].Duplicate) i++;
|
| 346 |
+
var runLength = i - start;
|
| 347 |
+
|
| 348 |
+
if (runLength < minRun)
|
| 349 |
+
for (var k = start; k < i; k++)
|
| 350 |
+
decisions[k] = decisions[k] with { Duplicate = false };
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// ===================== 基础算子 =====================
|
| 355 |
+
|
| 356 |
+
/// <summary>dHash:缩到 9×8 灰度,比较水平相邻像素得到 64 位哈希。</summary>
|
| 357 |
+
private static ulong DHash(byte[] bgra, int width, int height)
|
| 358 |
+
{
|
| 359 |
+
const int cols = 9, rows = 8;
|
| 360 |
+
var gray = new int[cols * rows];
|
| 361 |
+
|
| 362 |
+
// 盒式采样(每格取落在其范围内的像素平均),比双线性插值便宜且足够稳定
|
| 363 |
+
for (var r = 0; r < rows; r++)
|
| 364 |
+
{
|
| 365 |
+
var y0 = r * height / rows;
|
| 366 |
+
var y1 = Math.Max(y0 + 1, (r + 1) * height / rows);
|
| 367 |
+
for (var c = 0; c < cols; c++)
|
| 368 |
+
{
|
| 369 |
+
var x0 = c * width / cols;
|
| 370 |
+
var x1 = Math.Max(x0 + 1, (c + 1) * width / cols);
|
| 371 |
+
long acc = 0;
|
| 372 |
+
var n = 0;
|
| 373 |
+
for (var y = y0; y < y1 && y < height; y++)
|
| 374 |
+
{
|
| 375 |
+
var rowBase = y * width;
|
| 376 |
+
for (var x = x0; x < x1 && x < width; x++)
|
| 377 |
+
{
|
| 378 |
+
var p = (rowBase + x) * 4;
|
| 379 |
+
acc += (bgra[p + 2] * 299 + bgra[p + 1] * 587 + bgra[p] * 114) / 1000;
|
| 380 |
+
n++;
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
gray[r * cols + c] = n > 0 ? (int)(acc / n) : 0;
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
ulong hash = 0;
|
| 388 |
+
var bit = 0;
|
| 389 |
+
for (var r = 0; r < rows; r++)
|
| 390 |
+
for (var c = 0; c < cols - 1; c++, bit++)
|
| 391 |
+
if (gray[r * cols + c] > gray[r * cols + c + 1])
|
| 392 |
+
hash |= 1UL << bit;
|
| 393 |
+
return hash;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
private static int HammingDistance(ulong a, ulong b)
|
| 397 |
+
=> System.Numerics.BitOperations.PopCount(a ^ b);
|
| 398 |
+
|
| 399 |
+
/// <summary>第三阶:QR 分解精判(对应论文第 5 节)。
|
| 400 |
+
/// 流程:图像按 8×8 分块 → 每块 QR 分解 → 取 R 对角线当特征 v_k = diag(R_k)(长度 B=8)
|
| 401 |
+
/// → 全部 K 块拼起来算 p=2 的 Minkowski 距离 D_Mink。
|
| 402 |
+
///
|
| 403 |
+
/// 归一化说明(与论文的唯一差异,属修正而非改动算法):
|
| 404 |
+
/// 论文写的是 D_norm = D_Mink/(K·B)。但 D_Mink 是"平方和开根号",随特征值个数的**平方根**增长,
|
| 405 |
+
/// 而分母是特征值个数本身(1080p = 32400 块 × 8 = 259200),结果被压到 1e-3 量级;
|
| 406 |
+
/// 阈值 0.05 换算过去等价于"平均每个特征值可以差 25 个灰阶",真实近似帧只差 0.5~4.5 灰阶
|
| 407 |
+
/// → 该阶永远判重复、形同虚设(分辨率越高越严重)。
|
| 408 |
+
/// 这里改为除以 √(K·B):结果即"特征值的 RMS 灰阶差",量纲直观、与分辨率无关,阈值可实测标定。
|
| 409 |
+
/// 实测(1080p 动画 MV 3600 帧):候选帧对落在 0 ~ 4.5 灰阶,中位 0.53,阈值取 2.0。
|
| 410 |
+
/// 说明:论文的 R 来自 LAPACK 风格 QR,对角线符号随数据翻转;这里用改进 Gram-Schmidt 取非负对角线,
|
| 411 |
+
/// 仍是 R 的对角线但不会因符号翻转让距离突变。</summary>
|
| 412 |
+
private static double QrDistance(byte[] a, byte[] b, int width, int height)
|
| 413 |
+
{
|
| 414 |
+
const int B = 8;
|
| 415 |
+
var blocksX = width / B;
|
| 416 |
+
var blocksY = height / B;
|
| 417 |
+
if (blocksX <= 0 || blocksY <= 0) return double.MaxValue;
|
| 418 |
+
|
| 419 |
+
var ma = new double[B * B];
|
| 420 |
+
var mb = new double[B * B];
|
| 421 |
+
var q = new double[B * B];
|
| 422 |
+
var tmp = new double[B];
|
| 423 |
+
var diagA = new double[B];
|
| 424 |
+
var diagB = new double[B];
|
| 425 |
+
|
| 426 |
+
double sumSq = 0;
|
| 427 |
+
var k = 0;
|
| 428 |
+
|
| 429 |
+
for (var by = 0; by < blocksY; by++)
|
| 430 |
+
{
|
| 431 |
+
for (var bx = 0; bx < blocksX; bx++)
|
| 432 |
+
{
|
| 433 |
+
var x0 = bx * B;
|
| 434 |
+
var y0 = by * B;
|
| 435 |
+
for (var yy = 0; yy < B; yy++)
|
| 436 |
+
{
|
| 437 |
+
var rowBase = (y0 + yy) * width;
|
| 438 |
+
for (var xx = 0; xx < B; xx++)
|
| 439 |
+
{
|
| 440 |
+
var p = (rowBase + x0 + xx) * 4;
|
| 441 |
+
var i = yy * B + xx;
|
| 442 |
+
ma[i] = (a[p + 2] * 299 + a[p + 1] * 587 + a[p] * 114) / 1000.0;
|
| 443 |
+
mb[i] = (b[p + 2] * 299 + b[p + 1] * 587 + b[p] * 114) / 1000.0;
|
| 444 |
+
}
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
QrDiagonal(ma, B, q, tmp, diagA);
|
| 448 |
+
QrDiagonal(mb, B, q, tmp, diagB);
|
| 449 |
+
|
| 450 |
+
double blockSq = 0;
|
| 451 |
+
for (var t = 0; t < B; t++)
|
| 452 |
+
{
|
| 453 |
+
var d = diagA[t] - diagB[t];
|
| 454 |
+
blockSq += d * d;
|
| 455 |
+
}
|
| 456 |
+
sumSq += blockSq;
|
| 457 |
+
k++;
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
if (k == 0) return double.MaxValue;
|
| 462 |
+
// 归一化:除以 √(K·B) → 特征值的 RMS 灰阶差(见方法注释)
|
| 463 |
+
return Math.Sqrt(sumSq) / Math.Sqrt((double)k * B);
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
/// <summary>改进 Gram-Schmidt 求 R 的对角线(只需 diag,无需保留完整 R)。
|
| 467 |
+
/// 8×8 小矩阵做一遍再正交化即可。
|
| 468 |
+
/// 按列处理:对第 j 列减去它在前面各正交列上的投影,剩下的模长就是 r_jj。</summary>
|
| 469 |
+
private static void QrDiagonal(double[] m, int B, double[] q, double[] tmp, double[] diag)
|
| 470 |
+
{
|
| 471 |
+
for (var j = 0; j < B; j++)
|
| 472 |
+
{
|
| 473 |
+
for (var i = 0; i < B; i++) tmp[i] = m[i * B + j];
|
| 474 |
+
for (var pass = 0; pass < 2; pass++)
|
| 475 |
+
for (var p = 0; p < j; p++)
|
| 476 |
+
{
|
| 477 |
+
double dot = 0;
|
| 478 |
+
for (var i = 0; i < B; i++) dot += q[i * B + p] * tmp[i];
|
| 479 |
+
for (var i = 0; i < B; i++) tmp[i] -= dot * q[i * B + p];
|
| 480 |
+
}
|
| 481 |
+
double norm = 0;
|
| 482 |
+
for (var i = 0; i < B; i++) norm += tmp[i] * tmp[i];
|
| 483 |
+
norm = Math.Sqrt(norm);
|
| 484 |
+
diag[j] = norm;
|
| 485 |
+
if (norm > 1e-12)
|
| 486 |
+
for (var i = 0; i < B; i++) q[i * B + j] = tmp[i] / norm;
|
| 487 |
+
else
|
| 488 |
+
for (var i = 0; i < B; i++) q[i * B + j] = 0;
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
/// <summary>读取 PNG 为 BGRA8 像素(用系统自带的 WIC 解码,不引入第三方依赖)。</summary>
|
| 493 |
+
private static async Task<(byte[] Pixels, int Width, int Height)> LoadBgraAsync(string path)
|
| 494 |
+
{
|
| 495 |
+
using var stream = File.OpenRead(path);
|
| 496 |
+
var decoder = await BitmapDecoder.CreateAsync(stream.AsRandomAccessStream());
|
| 497 |
+
var data = await decoder.GetPixelDataAsync(
|
| 498 |
+
BitmapPixelFormat.Bgra8,
|
| 499 |
+
BitmapAlphaMode.Premultiplied,
|
| 500 |
+
new BitmapTransform(),
|
| 501 |
+
ExifOrientationMode.IgnoreExifOrientation,
|
| 502 |
+
ColorManagementMode.DoNotColorManage);
|
| 503 |
+
return (data.DetachPixelData(), (int)decoder.PixelWidth, (int)decoder.PixelHeight);
|
| 504 |
+
}
|
| 505 |
+
}
|