analysis

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 30, 2026 License: MIT Imports: 29 Imported by: 0

Documentation

Overview

Package analysis orchestrates the analysis stages of a media file.

Analyzer.Analyze runs the technical analysis of one file. Stage 1 (probe + bitstream) reads container metadata and packets without decoding and completes in well under a second. Stage 2 decodes the video once and fans frames out to every visual analyzer.

Analyzer.Compare measures a distorted video against its reference (VMAF with a confidence interval, other metrics, viewing devices) through its Meter, quality.Meter in practice: both files are inspected (stage 1 only), then measured as quality.Options says. Passing reports already computed (CompareOptions.Reference, CompareOptions.Distorted) skips their inspection.

Index

Examples

Constants

View Source
const (
	StageProbe  = "probe"
	StageDecode = "decode"
)

Stage names reported through Progress.

View Source
const SchemaVersion = 1

SchemaVersion is the version of the JSON report layout.

Variables

View Source
var ErrNoMeter = errors.New("no quality meter configured")

ErrNoMeter is returned by Compare when the Analyzer has no quality meter.

View Source
var ErrNoVideo = errors.New("no video stream")

ErrNoVideo is returned when the input has no video stream.

Functions

This section is empty.

Types

type Analyzer

type Analyzer struct {
	// contains filtered or unexported fields
}

Analyzer runs the analysis pipeline.

func New

func New(
	logger *slog.Logger,
	prober probe.Prober,
	packets bitstream.PacketReader,
	decoder decode.Source,
	meter Meter,
) *Analyzer

New returns an Analyzer. logger may be nil (nothing is logged) and meter may be nil when Compare is not used.

func (*Analyzer) Analyze

func (a *Analyzer) Analyze(
	ctx context.Context,
	path string,
	opts Options,
) (*Report, error)

Analyze runs every stage on path: inspection, then, unless opts.SkipVideo, the decoded-frame analysis.

Example

The technical analysis of one file: bitrate, GOP structure, then one decode for SI/TI, shots, black and frozen segments, crop and levels.

package main

import (
	"context"
	"fmt"
	"log/slog"

	"github.com/eko/qc/analysis"
	"github.com/eko/qc/bitstream"
	"github.com/eko/qc/decode"
	"github.com/eko/qc/probe"
	"github.com/eko/qc/quality"
	"github.com/eko/qc/vmaf/libvmaf"
)

// exampleAnalyzer wires an Analyzer on the ffmpeg and ffprobe binaries.
func exampleAnalyzer() *analysis.Analyzer {
	dec := decode.NewFFmpeg("ffmpeg", 0)

	return analysis.New(slog.Default(),
		probe.NewFFprobe("ffprobe"),
		bitstream.NewFFprobeReader("ffprobe"),
		dec,
		quality.NewMeter(dec, libvmaf.NewEngine()),
	)
}

func main() {
	report, err := exampleAnalyzer().Analyze(context.Background(), "video.mp4", analysis.Options{})
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Printf("%d shots, SI %.0f, TI %.0f\n", len(report.Video.Shots),
		report.Video.SITI.SISummary.Mean, report.Video.SITI.TISummary.Mean)
}
Example (Audio)

The audio of the analysis: every track's loudness checked against a target, here ATSC A/85, and its defects.

package main

import (
	"context"
	"fmt"
	"log/slog"

	"github.com/eko/qc/analysis"
	"github.com/eko/qc/audio/loudness"
	"github.com/eko/qc/bitstream"
	"github.com/eko/qc/decode"
	"github.com/eko/qc/probe"
	"github.com/eko/qc/quality"
	"github.com/eko/qc/vmaf/libvmaf"
)

// exampleAnalyzer wires an Analyzer on the ffmpeg and ffprobe binaries.
func exampleAnalyzer() *analysis.Analyzer {
	dec := decode.NewFFmpeg("ffmpeg", 0)

	return analysis.New(slog.Default(),
		probe.NewFFprobe("ffprobe"),
		bitstream.NewFFprobeReader("ffprobe"),
		dec,
		quality.NewMeter(dec, libvmaf.NewEngine()),
	)
}

func main() {
	atsc, _ := loudness.ParseTarget(loudness.TargetATSC)

	report, err := exampleAnalyzer().Analyze(context.Background(), "video.mp4", analysis.Options{
		Audio: analysis.AudioOptions{Target: atsc},
	})
	if err != nil || report.Audio == nil {
		fmt.Println(err)

		return
	}

	for _, t := range report.Audio.Tracks {
		fmt.Printf("#%d %.1f LKFS, LRA %.1f LU, true peak %.1f dBTP, on target: %v, silences: %d\n",
			t.Stream, t.Loudness.Integrated, t.Loudness.Range, t.Loudness.TruePeak, t.Compliance.OK(), len(t.Defects.Silence))
	}
}
Example (Hdr)

An HDR (PQ or HLG) video also gets its light levels: the CTA-861.3 MaxCLL and MaxFALL measured on the decoded frames, next to the values it signals, and a comparison against an HDR reference reports the HDR metrics (wPSNR, ΔE ITP) and how to read VMAF on it.

package main

import (
	"context"
	"fmt"
	"log/slog"

	"github.com/eko/qc/analysis"
	"github.com/eko/qc/bitstream"
	"github.com/eko/qc/decode"
	"github.com/eko/qc/probe"
	"github.com/eko/qc/quality"
	"github.com/eko/qc/vmaf/libvmaf"
)

// exampleAnalyzer wires an Analyzer on the ffmpeg and ffprobe binaries.
func exampleAnalyzer() *analysis.Analyzer {
	dec := decode.NewFFmpeg("ffmpeg", 0)

	return analysis.New(slog.Default(),
		probe.NewFFprobe("ffprobe"),
		bitstream.NewFFprobeReader("ffprobe"),
		dec,
		quality.NewMeter(dec, libvmaf.NewEngine()),
	)
}

func main() {
	analyzer := exampleAnalyzer()

	report, err := analyzer.Analyze(context.Background(), "hdr10.mov", analysis.Options{})
	if err != nil {
		fmt.Println(err)

		return
	}

	video, _ := report.Info.PrimaryVideo()
	fmt.Println(video.HDR.DynamicRange) // HDR10, HLG, HDR10+, DolbyVision...

	if l := report.Video.Light; l != nil {
		fmt.Printf("MaxCLL %.0f (strict %.0f) MaxFALL %.0f cd/m²\n", l.MaxCLLRobust, l.MaxCLL, l.MaxFALL)
	}

	if cll := video.HDR.ContentLightLevel; cll != nil {
		fmt.Printf("signalled %d / %d cd/m²\n", cll.MaxCLL, cll.MaxFALL)
	}

	cmp, err := analyzer.Compare(context.Background(), "hdr10.mov", "encode.mp4", analysis.CompareOptions{
		Quality: quality.Options{HDRMetric: quality.HDRMetricToneMap}, // VMAF on an SDR tone mapping
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	for _, name := range []string{quality.SeriesWPSNRY, quality.SeriesDeltaEITP} {
		if m, ok := cmp.VMAF.Metric(name); ok {
			fmt.Printf("%s %.2f ± %.2f\n", quality.DescribeSeries(name).Label, m.Mean, m.HalfWidth)
		}
	}

	if h := cmp.VMAF.HDR; h != nil {
		fmt.Println(h.Note)
	}
}

func (*Analyzer) Compare

func (a *Analyzer) Compare(
	ctx context.Context,
	refPath, distPath string,
	opts CompareOptions,
) (*Comparison, error)

Compare inspects both files (no decoding) then measures VMAF of dist against ref. It returns ErrNoMeter when the Analyzer has no quality meter.

Example

VMAF with a 95% confidence interval, plus other metrics and the VMAF of viewing devices, measured on the same sampled frames.

package main

import (
	"context"
	"fmt"
	"log/slog"

	"github.com/eko/qc/analysis"
	"github.com/eko/qc/bitstream"
	"github.com/eko/qc/decode"
	"github.com/eko/qc/probe"
	"github.com/eko/qc/quality"
	"github.com/eko/qc/vmaf"
	"github.com/eko/qc/vmaf/libvmaf"
)

// exampleAnalyzer wires an Analyzer on the ffmpeg and ffprobe binaries.
func exampleAnalyzer() *analysis.Analyzer {
	dec := decode.NewFFmpeg("ffmpeg", 0)

	return analysis.New(slog.Default(),
		probe.NewFFprobe("ffprobe"),
		bitstream.NewFFprobeReader("ffprobe"),
		dec,
		quality.NewMeter(dec, libvmaf.NewEngine()),
	)
}

func main() {
	cmp, err := exampleAnalyzer().Compare(context.Background(), "reference.mov", "encode.mp4", analysis.CompareOptions{
		Quality: quality.Options{
			Precision: 0.5, // ± VMAF points; Exact: true scores every frame
			Metrics:   []string{quality.MetricXPSNR, quality.MetricCAMBI, quality.MetricMSSSIM},
			Devices:   []string{vmaf.DevicePhone, vmaf.Device4K},
		},
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	res := cmp.VMAF
	fmt.Printf("VMAF %.2f ± %.2f\n", res.Mean, res.HalfWidth)

	if m, ok := res.Metric(quality.SeriesXPSNRY); ok {
		fmt.Printf("XPSNR Y %.2f dB [%.2f, %.2f]\n", m.Mean, m.Low, m.High)
	}

	if d, ok := res.Device(vmaf.DevicePhone); ok {
		fmt.Printf("phone VMAF %.2f\n", d.Mean)
	}

	if res.Banding != nil {
		for _, s := range res.Banding.Segments {
			fmt.Printf("banding %v–%v, CAMBI peak %.1f\n", s.Start, s.End, s.Peak)
		}
	}
}
Example (Budget)

A fixed budget instead of a precision target. Passing the analysed encode skips inspecting it again, and its shot cuts become the scenes of a per-scene budget (keyframes alone otherwise).

package main

import (
	"context"
	"fmt"
	"log/slog"

	"github.com/eko/qc/analysis"
	"github.com/eko/qc/bitstream"
	"github.com/eko/qc/decode"
	"github.com/eko/qc/probe"
	"github.com/eko/qc/quality"
	"github.com/eko/qc/vmaf/libvmaf"
)

// exampleAnalyzer wires an Analyzer on the ffmpeg and ffprobe binaries.
func exampleAnalyzer() *analysis.Analyzer {
	dec := decode.NewFFmpeg("ffmpeg", 0)

	return analysis.New(slog.Default(),
		probe.NewFFprobe("ffprobe"),
		bitstream.NewFFprobeReader("ffprobe"),
		dec,
		quality.NewMeter(dec, libvmaf.NewEngine()),
	)
}

func main() {
	ctx := context.Background()
	analyzer := exampleAnalyzer()

	encode, err := analyzer.Analyze(ctx, "encode.mp4", analysis.Options{})
	if err != nil {
		fmt.Println(err)

		return
	}

	budget, _ := quality.ParseSample("2/scene") // or "5%"

	cmp, err := analyzer.Compare(ctx, "reference.mov", "encode.mp4", analysis.CompareOptions{
		Quality:   quality.Options{Sample: budget},
		Distorted: encode,
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Printf("VMAF %.2f ± %.2f from %d of %d frames\n",
		cmp.VMAF.Mean, cmp.VMAF.HalfWidth, cmp.VMAF.FramesScored, cmp.VMAF.FramesTotal)
}
Example (Gpu)

VMAF on an NVIDIA GPU: NVDEC decoding (frames identical to a CPU decode) and CUDA feature extraction when the model has CUDA features. VMAF v1, the default model, has none: BackendAuto then stays on the CPU and says why in BackendNote. CUDA VMAF needs a binary built with -tags cuda against a CUDA libvmaf (libvmaf.CUDABuilt).

package main

import (
	"context"
	"fmt"
	"log/slog"

	"github.com/eko/qc/analysis"
	"github.com/eko/qc/bitstream"
	"github.com/eko/qc/decode"
	"github.com/eko/qc/probe"
	"github.com/eko/qc/quality"
	"github.com/eko/qc/vmaf"
	"github.com/eko/qc/vmaf/libvmaf"
)

func main() {
	dec := decode.NewFFmpeg("ffmpeg", 0, decode.WithHWAccel(decode.HWAccelCUDA))
	analyzer := analysis.New(slog.Default(),
		probe.NewFFprobe("ffprobe"),
		bitstream.NewFFprobeReader("ffprobe"),
		dec,
		quality.NewMeter(dec, libvmaf.NewEngine()),
	)

	cmp, err := analyzer.Compare(context.Background(), "reference.mov", "encode.mp4", analysis.CompareOptions{
		Quality: quality.Options{
			Model:   "vmaf_v0.6.1", // has CUDA features, unlike VMAF v1
			Backend: vmaf.BackendAuto,
		},
	})
	if err != nil {
		fmt.Println(err)

		return
	}

	fmt.Printf("VMAF %.2f ± %.2f\n", cmp.VMAF.Mean, cmp.VMAF.HalfWidth)
	fmt.Println(cmp.VMAF.GPUSummary()) // e.g. NVDEC decoding (cuda) · VMAF features on CUDA
}

type AudioDecoder

type AudioDecoder interface {
	DecodeAudio(
		ctx context.Context,
		req decode.AudioRequest,
		fn func(samples [][]float32) error,
	) error
}

AudioDecoder is the optional part of a decode.Source (decode.FFmpeg) that decodes audio streams into planar float32 samples. With a decoder lacking it, the analysis leaves the audio out.

type AudioOptions

type AudioOptions struct {
	// Skip leaves the audio out.
	Skip bool
	// WithInspection analyses the audio even with SkipVideo, which
	// otherwise decodes nothing.
	WithInspection bool
	// DefaultTrack analyses the default track only (media.Info's
	// DefaultAudio); otherwise Tracks lists the tracks analysed (indices
	// into media.Info.Audio, out-of-range ones ignored), every track when
	// empty.
	DefaultTrack bool
	Tracks       []int
	// Target is the loudness target (zero: EBU R 128).
	Target loudness.Target
	// Defect tunes the defect detection (silence, clipping, phase).
	Defect defect.Options
}

AudioOptions configures the audio analysis: loudness and defects of the audio tracks, decoded while the video is (package audio). The zero value analyses every track of a frame analysis against EBU R 128.

type AudioReport

type AudioReport struct {
	Target loudness.Target `json:"target"`
	Tracks []audio.Track   `json:"tracks"`
}

AudioReport is the audio analysis: every analysed track, checked against the same target.

type CompareOptions

type CompareOptions struct {
	// Bitstream configures the packet analysis of the files a comparison
	// inspects (it never runs the frame analysis).
	Bitstream bitstream.Options
	// Quality configures the measurement.
	Quality quality.Options
	// Reference, when set, is the already inspected reference: repeated
	// comparisons against one reference skip inspecting it again.
	Reference *Report
	// Distorted, when set, is the already analysed distorted video: it is
	// not inspected again, and with a fixed budget (Quality.Sample) its shot
	// cuts become Quality.Cuts unless those are set. Only its inspection
	// (no frame analysis) is kept in the Comparison.
	Distorted *Report
}

CompareOptions configures a comparison. The zero value is valid.

type Comparison

type Comparison struct {
	SchemaVersion int               `json:"schemaVersion"`
	GeneratedAt   time.Time         `json:"generatedAt"`
	Reference     *Report           `json:"reference"`
	Distorted     *Report           `json:"distorted"`
	VMAF          *quality.Result   `json:"vmaf"`
	Timings       map[string]string `json:"timings"`
}

Comparison is the result of comparing a distorted video to its reference.

type ComplexityHint

type ComplexityHint struct {
	Spatial  string `json:"spatial"`
	Temporal string `json:"temporal"`
}

ComplexityHint is a coarse encoding difficulty classification from SI/TI.

type FrameSeries

type FrameSeries struct {
	PTS        []media.Duration `json:"pts"`
	Size       []int            `json:"size"`
	Keyframe   []bool           `json:"keyframe"`
	SI         []float64        `json:"si"`
	TI         []float64        `json:"ti"`
	SceneScore []float64        `json:"sceneScore"`
	LumaMean   []float64        `json:"lumaMean"`
	LumaMin    []float64        `json:"lumaMin"`
	LumaMax    []float64        `json:"lumaMax"`
	// PeakNits, RobustPeakNits and AverageNits are the brightest max(R,
	// G, B) of each frame, its 99.9th percentile (light.RobustPercentile)
	// and its average, in cd/m², for PQ and HLG videos only.
	PeakNits       []float64 `json:"peakNits,omitempty"`
	RobustPeakNits []float64 `json:"robustPeakNits,omitempty"`
	AverageNits    []float64 `json:"averageNits,omitempty"`
	// MotionPan and MotionTilt (% of the width per second, positive when
	// the camera turns right or up), MotionZoom (% per second, positive
	// when zooming in) and MotionRoll (° per second, clockwise) are the
	// low-passed camera move; MotionShake is the jitter of the camera path
	// (% of the width) and MotionConfidence the confidence (0-1) of each
	// frame's estimate. They are absent when the motion analysis was
	// skipped.
	MotionPan        []float64 `json:"motionPan,omitempty"`
	MotionTilt       []float64 `json:"motionTilt,omitempty"`
	MotionZoom       []float64 `json:"motionZoom,omitempty"`
	MotionRoll       []float64 `json:"motionRoll,omitempty"`
	MotionShake      []float64 `json:"motionShake,omitempty"`
	MotionConfidence []float64 `json:"motionConfidence,omitempty"`
}

FrameSeries holds per-frame values as columns, which keeps the JSON compact and ready for charting.

type HDRProber

type HDRProber interface {
	// ProbeStreams reads the container and streams without any frame.
	ProbeStreams(
		ctx context.Context,
		path string,
	) (*media.Info, error)
	// ProbeHDR completes the HDR metadata of info from its first frame.
	ProbeHDR(
		ctx context.Context,
		info *media.Info,
	) error
}

HDRProber is the optional part of a Prober (probe.FFprobe) that reads the HDR side data of a video's first frame apart from its streams. The Analyzer then reads it while it decodes or measures, instead of before: that extra ffprobe call costs about 65 ms, more than the whole inspection of a short file.

type Meter

type Meter interface {
	Measure(
		ctx context.Context,
		ref, dist quality.Input,
		opts quality.Options,
	) (*quality.Result, error)
}

Meter measures the quality of a distorted video against its reference (*quality.Meter): the port Compare measures through.

type Options

type Options struct {
	Bitstream bitstream.Options
	Video     VideoOptions
	// Audio configures the audio analysis, which runs with the frame
	// analysis (and not with SkipVideo unless Audio.WithInspection).
	Audio AudioOptions
	// SkipVideo only runs stage 1 (no decoding).
	SkipVideo bool
	// DeferHDRMetadata leaves the HDR metadata of the first frame out of an
	// inspection (SkipVideo): the dynamic range then relies on the
	// container's signalling. Set it when a frame analysis of the same file
	// follows: that analysis reads the first frame while it decodes.
	DeferHDRMetadata bool
	// Progress, when set, is called as stages advance. It must be fast.
	Progress func(Progress)
}

Options configures an analysis. The zero value is valid.

type Progress

type Progress struct {
	Stage string
	Done  int
	Total int
}

Progress reports the advancement of a stage. Total is 0 when unknown.

type Report

type Report struct {
	SchemaVersion int               `json:"schemaVersion"`
	GeneratedAt   time.Time         `json:"generatedAt"`
	Info          *media.Info       `json:"info"`
	Bitstream     *bitstream.Report `json:"bitstream,omitempty"`
	Video         *VideoReport      `json:"video,omitempty"`
	Frames        *FrameSeries      `json:"frames,omitempty"`
	// Audio is the audio analysis (nil when skipped or without audio).
	Audio   *AudioReport      `json:"audio,omitempty"`
	Timings map[string]string `json:"timings"`
}

Report gathers the results of every stage.

func (*Report) ShotCuts

func (r *Report) ShotCuts() []media.Duration

ShotCuts returns the shot cuts found by the frame analysis: the start of every shot but the first. It is nil without a frame analysis.

type ShotReport

type ShotReport struct {
	scene.Shot
	Frames  int     `json:"frames"`
	SIMean  float64 `json:"siMean"`
	TIMean  float64 `json:"tiMean"`
	Bitrate int64   `json:"bitrate"`
	// Camera is the camera work of the shot (nil when the motion analysis
	// was skipped).
	Camera *motion.Shot `json:"camera,omitempty"`
}

ShotReport enriches a shot with its complexity and cost. Shots are the strata used later to sample frames for quality measurement.

type VideoOptions

type VideoOptions struct {
	Scene  scene.Options
	Black  black.Options
	Freeze freeze.Options
	Crop   crop.Options
	Motion motion.Options
	// SkipMotion leaves the camera motion analysis out.
	SkipMotion bool
	// Workers is the SI/TI worker count of a single-pass analysis (0 =
	// NumCPU).
	Workers int
	// Decoders is how many segments of the video are decoded and analysed
	// at once. 1 decodes the video in a single pass whose frames are
	// fanned out to the analyzers. 0 asks the decoder
	// (decode.SegmentDecoders), 1 when it cannot tell.
	Decoders int
	// DecoderThreads is the ffmpeg thread count of each segment's decoder
	// (0 = NumCPU / Decoders, at least 2). Hardware sessions do not use
	// them; a segment decoded on the CPU does.
	DecoderThreads int
}

VideoOptions tunes the decoded-frame analyzers. The zero value is valid.

type VideoReport

type VideoReport struct {
	FramesDecoded int            `json:"framesDecoded"`
	Levels        levels.Result  `json:"luma"`
	SITI          siti.Result    `json:"siti"`
	Shots         []ShotReport   `json:"shots"`
	Black         black.Result   `json:"black"`
	Freeze        freeze.Result  `json:"freeze"`
	Crop          crop.Result    `json:"crop"`
	Complexity    ComplexityHint `json:"complexity"`
	// Light holds the light levels of a PQ or HLG video (MaxCLL, MaxFALL),
	// nil for SDR.
	Light *light.Result `json:"light,omitempty"`
	// Motion sums up the camera work of the shots (nil when the motion
	// analysis was skipped).
	Motion *motion.Summary `json:"motion,omitempty"`
}

VideoReport summarises the decoded-frame analysis.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL