feat: massive additions to output csv

This commit is contained in:
aria 2025-06-14 02:14:20 +10:00
parent 4699841f1e
commit e22c33b78e
Signed by: aria
SSH key fingerprint: SHA256:WqtcVnDMrv1lnUlNah5k31iywFUI/DV+5yHzCTO4Vds
2 changed files with 171 additions and 14 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
This is a Go CLI application for video frame analysis and comparison. The project provides tools to count frames in videos, compare individual frames, analyze differences between two videos, and perform frame persistence analysis for single videos. This is a Go CLI application for professional video frame analysis and comparison. The project provides tools to count frames in videos, compare individual frames, analyze differences between two videos, and perform comprehensive frame persistence analysis for single videos with DigitalFoundry-style CSV output.
## Core Architecture ## Core Architecture
@ -12,13 +12,15 @@ The application is built using:
- **CLI Framework**: urfave/cli/v3 for command-line interface - **CLI Framework**: urfave/cli/v3 for command-line interface
- **Video Processing**: AlexEidt/Vidio library for video file handling with FPS detection - **Video Processing**: AlexEidt/Vidio library for video file handling with FPS detection
- **Image Processing**: Standard Go image libraries for frame comparison - **Image Processing**: Standard Go image libraries for frame comparison
- **CSV Export**: Built-in CSV generation for professional video analysis visualization
### Main Components ### Main Components
- **CLI Commands**: Five main commands for comprehensive frame analysis operations - **CLI Commands**: Five main commands for comprehensive frame analysis operations
- **Frame Comparison**: Pixel-level comparison with configurable tolerance using squared difference - **Frame Comparison**: Pixel-level comparison with configurable tolerance using squared difference
- **Video Processing**: Frame-by-frame video analysis with streaming support and memory-efficient processing - **Video Processing**: Frame-by-frame video analysis with streaming support and memory-efficient processing
- **Frame Persistence Analysis**: Detects consecutive duplicate frames and calculates persistence duration - **Two-Pass Analysis**: Advanced frame persistence analysis with pre-calculated total durations
- **CSV Generation**: DigitalFoundry-style data export for professional visualization tools
### Key Functions ### Key Functions
@ -26,7 +28,7 @@ The application is built using:
- `compare_frames()`: Compares two frames with tolerance-based difference detection - `compare_frames()`: Compares two frames with tolerance-based difference detection
- `compare_frames_alt()`: Alternative frame comparison using exact pixel matching - `compare_frames_alt()`: Alternative frame comparison using exact pixel matching
- `countUniqueVideoFrames()`: Analyzes differences between corresponding frames in two videos - `countUniqueVideoFrames()`: Analyzes differences between corresponding frames in two videos
- `analyzeFramePersistence()`: **Main feature** - Analyzes frame persistence in single video with per-second statistics - `analyzeFramePersistence()`: **Main feature** - Two-pass frame persistence analysis with CSV export
- `isDiffUInt8WithTolerance()`: Pixel comparison with configurable tolerance threshold - `isDiffUInt8WithTolerance()`: Pixel comparison with configurable tolerance threshold
- `imageToRGBA()`: Converts images to RGBA format for consistent processing - `imageToRGBA()`: Converts images to RGBA format for consistent processing
@ -56,17 +58,63 @@ Available commands:
- `compare-frames <frame1> <frame2>` - Compare two image frames - `compare-frames <frame1> <frame2>` - Compare two image frames
- `count-frames-differing-pixels <frame1> <frame2>` - Count pixel differences between frames - `count-frames-differing-pixels <frame1> <frame2>` - Count pixel differences between frames
- `count-unique-video-frames <video1> <video2>` - Compare corresponding frames between two videos - `count-unique-video-frames <video1> <video2>` - Compare corresponding frames between two videos
- `analyze-frame-persistence [--tolerance float] <video>` - **Main feature**: Analyze frame persistence and unique frames per second - `analyze-frame-persistence [--tolerance float] [--csv-output path] <video>` - **Main feature**: Professional video analysis with CSV export
### Frame Persistence Analysis ### Frame Persistence Analysis with CSV Export
The main feature provides: The main feature provides:
- Real-time FPS detection from video metadata - Real-time FPS detection from video metadata
- Frame-by-frame comparison with previous frame - Frame-by-frame comparison with previous frame
- Detection of consecutive duplicate frame sequences (3+ identical frames) - Detection of consecutive duplicate frame sequences (3+ identical frames)
- Per-second unique frame counting - Per-second unique frame counting
- Persistence duration calculation in milliseconds - Two-pass analysis for accurate total frame persistence calculation
- Configurable pixel difference tolerance (0-255) - Configurable pixel difference tolerance (0-255)
- **Professional CSV export** with 5 columns for DigitalFoundry-style analysis
### CSV Output Format
The `--csv-output` flag generates a CSV file with these columns:
- `frame`: Frame number (1-based, no skipped frames)
- `average_fps`: Running effective FPS calculation
- `frame_time`: Current frame persistence duration (real-time)
- `unique_frame_count`: Cumulative unique frame count (stays constant during duplicates)
- `real_frame_time`: **Total persistence time for each unique frame (smooth for visualization)**
### CSV Usage Examples
```bash
# Basic analysis with CSV export
./fps-go-brr analyze-frame-persistence video.mp4 --csv-output analysis.csv
# With tolerance for noisy videos
./fps-go-brr analyze-frame-persistence video.mp4 --tolerance 10 --csv-output analysis.csv
```
## Advanced Implementation Details
### Two-Pass Analysis Architecture
The `analyzeFramePersistence()` function uses a sophisticated two-pass approach:
1. **First Pass**: Analyzes entire video to calculate total duration each unique frame will persist
2. **Second Pass**: Writes CSV with correct `real_frame_time` values for smooth visualization
This ensures:
- All instances of the same unique frame show identical `real_frame_time` values
- Creates smooth, non-jumpy graphs perfect for professional video analysis
- DigitalFoundry-style frame timing visualization compatibility
### Frame Data Structure
```go
type FrameData struct {
frameNumber int // Current frame number
uniqueFrameCount int // Cumulative unique frames
effectiveFPS float64 // Running average FPS
currentFrameTime float64 // Current persistence so far
realFrameTime float64 // Total persistence duration
}
```
## Implementation Notes ## Implementation Notes
@ -74,5 +122,7 @@ The main feature provides:
- Pixel comparison uses squared difference for tolerance-based matching - Pixel comparison uses squared difference for tolerance-based matching
- Video processing is done frame-by-frame to handle large files efficiently - Video processing is done frame-by-frame to handle large files efficiently
- Frame persistence detection only reports sequences of 3+ consecutive identical frames - Frame persistence detection only reports sequences of 3+ consecutive identical frames
- All image formats supported by Go's image package can be used for frame comparison - Two-pass analysis ensures accurate total persistence calculations for visualization
- The `analyze-frame-persistence` command is the primary tool for video quality analysis - CSV output is optimized for professional video analysis tools and graphing software
- The `analyze-frame-persistence` command is the primary tool for professional video quality analysis
- All image formats supported by Go's image package can be used for frame comparison

119
main.go
View file

@ -2,6 +2,8 @@ package main
import ( import (
"context" "context"
"encoding/csv"
"fmt"
"image" "image"
"image/draw" "image/draw"
"log" "log"
@ -97,10 +99,16 @@ func main() {
Usage: "Pixel difference tolerance (0-255)", Usage: "Pixel difference tolerance (0-255)",
Value: 0, Value: 0,
}, },
&cli.StringFlag{
Name: "csv-output",
Usage: "Path to CSV file for frame data output",
Value: "",
},
}, },
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
tolerance := uint64(cmd.Float64("tolerance")) tolerance := uint64(cmd.Float64("tolerance"))
return analyzeFramePersistence(cmd.StringArg("video"), tolerance) csvOutput := cmd.String("csv-output")
return analyzeFramePersistence(cmd.StringArg("video"), tolerance, csvOutput)
}, },
}, },
}, },
@ -240,7 +248,7 @@ func getImageFromFilePath(filePath string) (image.Image, error) {
return image, err return image, err
} }
func analyzeFramePersistence(videoPath string, tolerance uint64) error { func analyzeFramePersistence(videoPath string, tolerance uint64, csvOutput string) error {
video, err := vidio.NewVideo(videoPath) video, err := vidio.NewVideo(videoPath)
if err != nil { if err != nil {
return err return err
@ -252,10 +260,41 @@ func analyzeFramePersistence(videoPath string, tolerance uint64) error {
log.Default().Printf("Video FPS: %.2f, Frame time: %.2f ms", fps, frameTimeMs) log.Default().Printf("Video FPS: %.2f, Frame time: %.2f ms", fps, frameTimeMs)
var csvWriter *csv.Writer
var csvFile *os.File
if csvOutput != "" {
csvFile, err = os.Create(csvOutput)
if err != nil {
return fmt.Errorf("failed to create CSV file: %v", err)
}
defer csvFile.Close()
csvWriter = csv.NewWriter(csvFile)
defer csvWriter.Flush()
err = csvWriter.Write([]string{"frame", "average_fps", "frame_time", "unique_frame_count", "real_frame_time"})
if err != nil {
return fmt.Errorf("failed to write CSV header: %v", err)
}
}
// Data structures for frame analysis
type FrameData struct {
frameNumber int
uniqueFrameCount int
effectiveFPS float64
currentFrameTime float64
realFrameTime float64
}
var frameAnalysisData []FrameData
var uniqueFrameDurations []int // Duration of each unique frame
currentFrame := image.NewRGBA(image.Rect(0, 0, video.Width(), video.Height())) currentFrame := image.NewRGBA(image.Rect(0, 0, video.Width(), video.Height()))
previousFrame := image.NewRGBA(image.Rect(0, 0, video.Width(), video.Height())) previousFrame := image.NewRGBA(image.Rect(0, 0, video.Width(), video.Height()))
video.SetFrameBuffer(currentFrame.Pix) video.SetFrameBuffer(currentFrame.Pix)
// FIRST PASS: Analyze frame durations
var frameNumber int var frameNumber int
var uniqueFramesPerSecond []int var uniqueFramesPerSecond []int
var framePersistenceDurations []float64 var framePersistenceDurations []float64
@ -263,6 +302,8 @@ func analyzeFramePersistence(videoPath string, tolerance uint64) error {
currentSecond := 0 currentSecond := 0
uniqueFramesInCurrentSecond := 0 uniqueFramesInCurrentSecond := 0
consecutiveDuplicateCount := 0 consecutiveDuplicateCount := 0
totalUniqueFrames := 0
currentUniqueFrameDuration := 1
hasFirstFrame := false hasFirstFrame := false
@ -273,6 +314,20 @@ func analyzeFramePersistence(videoPath string, tolerance uint64) error {
copy(previousFrame.Pix, currentFrame.Pix) copy(previousFrame.Pix, currentFrame.Pix)
hasFirstFrame = true hasFirstFrame = true
uniqueFramesInCurrentSecond = 1 uniqueFramesInCurrentSecond = 1
totalUniqueFrames = 1
currentUniqueFrameDuration = 1
// Store data for first frame
currentTime := float64(frameNumber) / fps
effectiveFPS := float64(totalUniqueFrames) / currentTime
actualFrameTimeMs := float64(currentUniqueFrameDuration) * frameTimeMs
frameAnalysisData = append(frameAnalysisData, FrameData{
frameNumber: frameNumber,
uniqueFrameCount: totalUniqueFrames,
effectiveFPS: effectiveFPS,
currentFrameTime: actualFrameTimeMs,
realFrameTime: 0, // Will be calculated in second pass
})
continue continue
} }
@ -291,7 +346,17 @@ func analyzeFramePersistence(videoPath string, tolerance uint64) error {
if !isFrameDifferent { if !isFrameDifferent {
consecutiveDuplicateCount++ consecutiveDuplicateCount++
currentUniqueFrameDuration++
} else { } else {
// Record the duration of the previous unique frame
if totalUniqueFrames > 0 {
if len(uniqueFrameDurations) < totalUniqueFrames {
uniqueFrameDurations = append(uniqueFrameDurations, currentUniqueFrameDuration)
} else {
uniqueFrameDurations[totalUniqueFrames-1] = currentUniqueFrameDuration
}
}
if consecutiveDuplicateCount > 1 { if consecutiveDuplicateCount > 1 {
persistenceMs := float64(consecutiveDuplicateCount+1) * frameTimeMs persistenceMs := float64(consecutiveDuplicateCount+1) * frameTimeMs
framePersistenceDurations = append(framePersistenceDurations, persistenceMs) framePersistenceDurations = append(framePersistenceDurations, persistenceMs)
@ -300,9 +365,25 @@ func analyzeFramePersistence(videoPath string, tolerance uint64) error {
consecutiveDuplicateCount = 0 consecutiveDuplicateCount = 0
uniqueFramesInCurrentSecond++ uniqueFramesInCurrentSecond++
totalUniqueFrames++
copy(previousFrame.Pix, currentFrame.Pix) copy(previousFrame.Pix, currentFrame.Pix)
// Start tracking new unique frame
currentUniqueFrameDuration = 1
} }
// Store data for EVERY frame
currentTime := float64(frameNumber) / fps
effectiveFPS := float64(totalUniqueFrames) / currentTime
actualFrameTimeMs := float64(currentUniqueFrameDuration) * frameTimeMs
frameAnalysisData = append(frameAnalysisData, FrameData{
frameNumber: frameNumber,
uniqueFrameCount: totalUniqueFrames,
effectiveFPS: effectiveFPS,
currentFrameTime: actualFrameTimeMs,
realFrameTime: 0, // Will be calculated in second pass
})
newSecond := int(float64(frameNumber-1) / fps) newSecond := int(float64(frameNumber-1) / fps)
if newSecond > currentSecond { if newSecond > currentSecond {
uniqueFramesPerSecond = append(uniqueFramesPerSecond, uniqueFramesInCurrentSecond) uniqueFramesPerSecond = append(uniqueFramesPerSecond, uniqueFramesInCurrentSecond)
@ -312,6 +393,32 @@ func analyzeFramePersistence(videoPath string, tolerance uint64) error {
} }
} }
// Record the final unique frame duration
if totalUniqueFrames > 0 {
if len(uniqueFrameDurations) < totalUniqueFrames {
uniqueFrameDurations = append(uniqueFrameDurations, currentUniqueFrameDuration)
} else {
uniqueFrameDurations[totalUniqueFrames-1] = currentUniqueFrameDuration
}
}
// SECOND PASS: Calculate real frame times and write CSV
if csvWriter != nil {
for i, frameData := range frameAnalysisData {
realFrameTimeMs := float64(uniqueFrameDurations[frameData.uniqueFrameCount-1]) * frameTimeMs
err := csvWriter.Write([]string{
strconv.Itoa(frameData.frameNumber),
fmt.Sprintf("%.2f", frameData.effectiveFPS),
fmt.Sprintf("%.2f", frameData.currentFrameTime),
strconv.Itoa(frameData.uniqueFrameCount),
fmt.Sprintf("%.2f", realFrameTimeMs),
})
if err != nil {
log.Default().Printf("Warning: failed to write CSV row %d: %v", i+1, err)
}
}
}
if consecutiveDuplicateCount > 1 { if consecutiveDuplicateCount > 1 {
persistenceMs := float64(consecutiveDuplicateCount+1) * frameTimeMs persistenceMs := float64(consecutiveDuplicateCount+1) * frameTimeMs
framePersistenceDurations = append(framePersistenceDurations, persistenceMs) framePersistenceDurations = append(framePersistenceDurations, persistenceMs)
@ -327,15 +434,15 @@ func analyzeFramePersistence(videoPath string, tolerance uint64) error {
log.Default().Printf("Total frames analyzed: %d", frameNumber) log.Default().Printf("Total frames analyzed: %d", frameNumber)
log.Default().Printf("Video duration: %.2f seconds", float64(frameNumber)/fps) log.Default().Printf("Video duration: %.2f seconds", float64(frameNumber)/fps)
totalUniqueFrames := 0 summaryUniqueFrames := 0
for i, count := range uniqueFramesPerSecond { for i, count := range uniqueFramesPerSecond {
totalUniqueFrames += count summaryUniqueFrames += count
log.Default().Printf("Second %d: %d unique frames", i+1, count) log.Default().Printf("Second %d: %d unique frames", i+1, count)
} }
log.Default().Printf("Total unique frames: %d", totalUniqueFrames) log.Default().Printf("Total unique frames: %d", summaryUniqueFrames)
if len(uniqueFramesPerSecond) > 0 { if len(uniqueFramesPerSecond) > 0 {
log.Default().Printf("Average unique frames per second: %.2f", float64(totalUniqueFrames)/float64(len(uniqueFramesPerSecond))) log.Default().Printf("Average unique frames per second: %.2f", float64(summaryUniqueFrames)/float64(len(uniqueFramesPerSecond)))
} }
if len(framePersistenceDurations) > 0 { if len(framePersistenceDurations) > 0 {