Skip to content

Commit 4d2179b

Browse files
committed
feat(bilibili-analyzer): add frame deduplication with similarity detection
- Add `--similarity` parameter to control frame deduplication threshold (default 0.80) - Add `--no-dedup` flag to disable frame deduplication - Implement `DeduplicateFramesAsync()` method using ffmpeg PSNR/SSIM algorithms - Compare only consecutive frames to identify and remove similar duplicates - Automatically renumber remaining frames after deduplication - Update documentation with deduplication parameters and behavior explanation - Display deduplication status and similarity threshold in console output - Reduce redundant frames while preserving frame sequence integrity
1 parent 72865f9 commit 4d2179b

2 files changed

Lines changed: 202 additions & 0 deletions

File tree

skills/tools/bilibili-analyzer/SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,21 @@ dotnet run scripts/prepare.cs "https://www.bilibili.com/video/BV1xx411c7mD" -o .
110110
| `url` | B站视频URL(必需) | - |
111111
| `-o, --output` | 输出目录 | 当前目录 |
112112
| `--fps` | 每秒提取帧数 | 1.0 |
113+
| `--similarity` | 相似度阈值(0-1),超过此值的相邻帧会被去重 | 0.80 |
114+
| `--no-dedup` | 禁用相似帧去重 | false |
113115
| `--video-only` | 只下载视频,不提取帧 | false |
114116
| `--frames-only` | 只提取帧(需已有video.mp4) | false |
115117

118+
### 相似帧去重
119+
120+
脚本会自动对**相邻帧**进行相似度检测,去除相似度超过阈值(默认80%)的重复帧:
121+
122+
- 使用 ffmpeg 的 SSIM/PSNR 算法计算相似度
123+
- **只比较相邻帧**,不会跨帧比较
124+
- 去重后自动重新编号(frame_0001.jpg, frame_0002.jpg, ...)
125+
- 可通过 `--similarity 0.85` 调整阈值
126+
- 可通过 `--no-dedup` 禁用去重
127+
116128
### 输出结构
117129

118130
```

skills/tools/bilibili-analyzer/scripts/prepare.cs

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
#!/usr/bin/env dotnet run
22

33
using System;
4+
using System.Collections.Generic;
45
using System.Diagnostics;
56
using System.Globalization;
67
using System.IO;
78
using System.Linq;
89
using System.Net.Http;
10+
using System.Security.Cryptography;
911
using System.Text.Json;
1012
using System.Text.RegularExpressions;
1113
using System.Threading.Tasks;
@@ -25,8 +27,15 @@
2527
Console.WriteLine($"[ERROR] Invalid fps value: {fpsStr}");
2628
Environment.Exit(1);
2729
}
30+
var similarityStr = GetArgValue(args, "--similarity") ?? "0.80";
31+
if (!double.TryParse(similarityStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var similarityThreshold))
32+
{
33+
Console.WriteLine($"[ERROR] Invalid similarity value: {similarityStr}");
34+
Environment.Exit(1);
35+
}
2836
var videoOnly = args.Contains("--video-only");
2937
var framesOnly = args.Contains("--frames-only");
38+
var noDedup = args.Contains("--no-dedup");
3039

3140
var videoPath = Path.Combine(outputDir, "video.mp4");
3241
var imagesDir = Path.Combine(outputDir, "images");
@@ -40,6 +49,8 @@
4049
Console.WriteLine($"URL: {url}");
4150
Console.WriteLine($"Output: {outputDir}");
4251
Console.WriteLine($"FPS: {fps}");
52+
Console.WriteLine($"Similarity Threshold: {similarityThreshold:P0}");
53+
Console.WriteLine($"Deduplication: {(noDedup ? "Disabled" : "Enabled")}");
4354
Console.WriteLine(new string('=', 50));
4455

4556
// Download video
@@ -64,6 +75,12 @@
6475
{
6576
Environment.Exit(1);
6677
}
78+
79+
// Deduplicate similar frames
80+
if (!noDedup)
81+
{
82+
await DeduplicateFramesAsync(imagesDir, similarityThreshold);
83+
}
6784
}
6885

6986
Console.WriteLine();
@@ -294,6 +311,170 @@ async Task<bool> ExtractFramesAsync(string videoPath, string outputDir, double f
294311
}
295312
}
296313

314+
async Task DeduplicateFramesAsync(string imagesDir, double threshold)
315+
{
316+
Console.WriteLine($"[INFO] Deduplicating similar frames (threshold: {threshold:P0})...");
317+
318+
var files = Directory.GetFiles(imagesDir, "frame_*.jpg")
319+
.OrderBy(f => f)
320+
.ToList();
321+
322+
if (files.Count < 2)
323+
{
324+
Console.WriteLine("[INFO] Not enough frames to deduplicate");
325+
return;
326+
}
327+
328+
var toDelete = new List<string>();
329+
var ffmpeg = FindExecutable("ffmpeg", "ffmpeg.exe");
330+
331+
if (ffmpeg == null)
332+
{
333+
Console.WriteLine("[WARN] ffmpeg not found, skipping deduplication");
334+
return;
335+
}
336+
337+
// Compare consecutive frames only (not cross-frame comparison)
338+
for (int i = 0; i < files.Count - 1; i++)
339+
{
340+
var current = files[i];
341+
var next = files[i + 1];
342+
343+
// Skip if current frame is already marked for deletion
344+
if (toDelete.Contains(current))
345+
continue;
346+
347+
var similarity = await CalculateFrameSimilarityAsync(ffmpeg, current, next);
348+
349+
if (similarity >= threshold)
350+
{
351+
// Keep the first frame, mark the next one for deletion
352+
toDelete.Add(next);
353+
}
354+
}
355+
356+
// Delete similar frames
357+
foreach (var file in toDelete)
358+
{
359+
try
360+
{
361+
File.Delete(file);
362+
}
363+
catch { }
364+
}
365+
366+
// Renumber remaining frames
367+
var remainingFiles = Directory.GetFiles(imagesDir, "frame_*.jpg")
368+
.OrderBy(f => f)
369+
.ToList();
370+
371+
for (int i = 0; i < remainingFiles.Count; i++)
372+
{
373+
var newName = Path.Combine(imagesDir, $"frame_{i + 1:D4}.jpg");
374+
if (remainingFiles[i] != newName)
375+
{
376+
// Use temp name to avoid conflicts
377+
var tempName = Path.Combine(imagesDir, $"temp_{i + 1:D4}.jpg");
378+
File.Move(remainingFiles[i], tempName);
379+
}
380+
}
381+
382+
// Rename temp files to final names
383+
var tempFiles = Directory.GetFiles(imagesDir, "temp_*.jpg").OrderBy(f => f).ToList();
384+
for (int i = 0; i < tempFiles.Count; i++)
385+
{
386+
var finalName = Path.Combine(imagesDir, $"frame_{i + 1:D4}.jpg");
387+
File.Move(tempFiles[i], finalName);
388+
}
389+
390+
var finalCount = Directory.GetFiles(imagesDir, "frame_*.jpg").Length;
391+
Console.WriteLine($"[OK] Deduplication complete: {files.Count} -> {finalCount} frames (removed {toDelete.Count} similar frames)");
392+
}
393+
394+
async Task<double> CalculateFrameSimilarityAsync(string ffmpeg, string file1, string file2)
395+
{
396+
try
397+
{
398+
// Use ffmpeg to calculate PSNR (Peak Signal-to-Noise Ratio) between two images
399+
// Higher PSNR = more similar images
400+
var psi = new ProcessStartInfo
401+
{
402+
FileName = ffmpeg,
403+
Arguments = $"-i \"{file1}\" -i \"{file2}\" -lavfi \"psnr\" -f null -",
404+
RedirectStandardOutput = true,
405+
RedirectStandardError = true,
406+
UseShellExecute = false,
407+
CreateNoWindow = true
408+
};
409+
410+
using var process = Process.Start(psi);
411+
if (process == null) return 0;
412+
413+
var stderrTask = process.StandardError.ReadToEndAsync();
414+
await process.WaitForExitAsync();
415+
var stderr = await stderrTask;
416+
417+
// Parse PSNR value from output
418+
// Format: [Parsed_psnr_0 @ ...] PSNR y:XX.XX u:XX.XX v:XX.XX average:XX.XX min:XX.XX max:XX.XX
419+
var match = Regex.Match(stderr, @"average:(\d+\.?\d*)", RegexOptions.IgnoreCase);
420+
if (match.Success && double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var psnr))
421+
{
422+
// Convert PSNR to similarity percentage
423+
// PSNR > 40 dB is considered very similar (>95%)
424+
// PSNR > 30 dB is considered similar (>80%)
425+
// PSNR = infinity means identical images
426+
if (psnr > 100) return 1.0; // Identical or near-identical
427+
if (psnr > 40) return 0.95 + (psnr - 40) * 0.001;
428+
if (psnr > 30) return 0.80 + (psnr - 30) * 0.015;
429+
if (psnr > 20) return 0.50 + (psnr - 20) * 0.03;
430+
return psnr * 0.025;
431+
}
432+
433+
// Fallback: use simpler SSIM if PSNR parsing fails
434+
return await CalculateSSIMAsync(ffmpeg, file1, file2);
435+
}
436+
catch
437+
{
438+
return 0;
439+
}
440+
}
441+
442+
async Task<double> CalculateSSIMAsync(string ffmpeg, string file1, string file2)
443+
{
444+
try
445+
{
446+
var psi = new ProcessStartInfo
447+
{
448+
FileName = ffmpeg,
449+
Arguments = $"-i \"{file1}\" -i \"{file2}\" -lavfi \"ssim\" -f null -",
450+
RedirectStandardOutput = true,
451+
RedirectStandardError = true,
452+
UseShellExecute = false,
453+
CreateNoWindow = true
454+
};
455+
456+
using var process = Process.Start(psi);
457+
if (process == null) return 0;
458+
459+
var stderrTask = process.StandardError.ReadToEndAsync();
460+
await process.WaitForExitAsync();
461+
var stderr = await stderrTask;
462+
463+
// Parse SSIM value: All:0.XXXXX
464+
var match = Regex.Match(stderr, @"All:(\d+\.?\d*)", RegexOptions.IgnoreCase);
465+
if (match.Success && double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var ssim))
466+
{
467+
return ssim; // SSIM is already 0-1 range
468+
}
469+
470+
return 0;
471+
}
472+
catch
473+
{
474+
return 0;
475+
}
476+
}
477+
297478
string? FindExecutable(params string[] names)
298479
{
299480
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
@@ -393,6 +574,8 @@ url Bilibili video URL (required)
393574
Options:
394575
-o, --output <dir> Output directory (default: current)
395576
--fps <value> Frames per second (default: 1.0)
577+
--similarity <value> Similarity threshold for deduplication (default: 0.80)
578+
--no-dedup Disable frame deduplication
396579
--video-only Only download video, skip frame extraction
397580
--frames-only Only extract frames (requires existing video.mp4)
398581
-h, --help Show this help
@@ -401,6 +584,13 @@ url Bilibili video URL (required)
401584
dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD""
402585
dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" --fps 0.5
403586
dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" -o ./output
587+
dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" --similarity 0.85
588+
dotnet run prepare.cs ""https://www.bilibili.com/video/BV1xx411c7mD"" --no-dedup
589+
590+
Deduplication:
591+
Consecutive frames with similarity >= threshold will be deduplicated.
592+
Only compares adjacent frames (not cross-frame comparison).
593+
Uses ffmpeg SSIM/PSNR for accurate similarity calculation.
404594
405595
Requirements:
406596
- .NET 10 SDK

0 commit comments

Comments
 (0)