Sudabaka commited on
Commit
16100c3
·
verified ·
1 Parent(s): 65effbe

feat: 开源四阶判决器算法实现(FrameDedupService / FarnebackFlow)与完整训练流水线

Browse files
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
+ }