11#!/usr/bin/env dotnet run
22
33using System ;
4+ using System . Collections . Generic ;
45using System . Diagnostics ;
56using System . Globalization ;
67using System . IO ;
78using System . Linq ;
89using System . Net . Http ;
10+ using System . Security . Cryptography ;
911using System . Text . Json ;
1012using System . Text . RegularExpressions ;
1113using System . Threading . Tasks ;
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+ }
2836var videoOnly = args . Contains ( "--video-only" ) ;
2937var framesOnly = args . Contains ( "--frames-only" ) ;
38+ var noDedup = args . Contains ( "--no-dedup" ) ;
3039
3140var videoPath = Path . Combine ( outputDir , "video.mp4" ) ;
3241var imagesDir = Path . Combine ( outputDir , "images" ) ;
4049Console . WriteLine ( $ "URL: { url } ") ;
4150Console . WriteLine ( $ "Output: { outputDir } ") ;
4251Console . WriteLine ( $ "FPS: { fps } ") ;
52+ Console . WriteLine ( $ "Similarity Threshold: { similarityThreshold : P0} ") ;
53+ Console . WriteLine ( $ "Deduplication: { ( noDedup ? "Disabled" : "Enabled" ) } ") ;
4354Console . WriteLine ( new string ( '=' , 50 ) ) ;
4455
4556// Download video
6475 {
6576 Environment . Exit ( 1 ) ;
6677 }
78+
79+ // Deduplicate similar frames
80+ if ( ! noDedup )
81+ {
82+ await DeduplicateFramesAsync ( imagesDir , similarityThreshold ) ;
83+ }
6784}
6885
6986Console . 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+
297478string ? FindExecutable ( params string [ ] names )
298479{
299480 var pathEnv = Environment . GetEnvironmentVariable ( "PATH" ) ?? "" ;
@@ -393,6 +574,8 @@ url Bilibili video URL (required)
393574Options:
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
405595Requirements:
406596 - .NET 10 SDK
0 commit comments