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 ¶
const ( StageProbe = "probe" StageDecode = "decode" )
Stage names reported through Progress.
const SchemaVersion = 1
SchemaVersion is the version of the JSON report layout.
Variables ¶
var ErrNoMeter = errors.New("no quality meter configured")
ErrNoMeter is returned by Compare when the Analyzer has no quality meter.
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 ¶
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)
}
Output:
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))
}
}
Output:
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)
}
}
Output:
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)
}
}
}
Output:
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)
}
Output:
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
}
Output:
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 ¶
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 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.
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.