Skip to content

[SUGGESTION] pre-treatment video analisys/measurements #97

Description

@MarcoRavich

Hi there,
these days I'm testing HWEncoders' filters capabilities by @rigaya so I asked myself how to probe source video characteristics in order to rationally weight the color calibration / white balancing before the processing.

Inspired by @roaldarbol's vidstats I've asked Claude Sonnet 5 Max to generate a simple Windows batch file (.cmd) file to probe dropped videos on it by exploiting FFMPEG's (via ffprobe) signalstats and scdet filters:

@echo off
setlocal

rem ==========================================================================
rem  probe_video.cmd
rem
rem  Drag and drop one or more video files onto this script. For each one,
rem  ffprobe runs the signalstats + scdet filters over every frame and
rem  writes the results to "<video name>.txt" next to the source video.
rem
rem  signalstats reports per-frame luma/chroma/saturation/hue levels and
rem  frame-to-frame difference. scdet reports a change score (mafd/score)
rem  every frame and flags the exact frame where a hard scene cut is
rem  detected.
rem
rem  Requires ffprobe.exe (part of the FFmpeg Windows build) to be
rem  installed - it is not bundled with this script.
rem ==========================================================================

rem If ffprobe.exe is not on your PATH, point this at it directly, e.g.
rem set "FFPROBE=C:\ffmpeg\bin\ffprobe.exe"
set "FFPROBE=ffprobe"

rem Scene-cut sensitivity (0-100, lower = more sensitive). Default is 10.
set "SCDET_OPTS=scdet"
rem set "SCDET_OPTS=scdet=threshold=8"

if "%~1"=="" (
    echo Drag and drop one or more video files onto this script.
    echo.
    pause
    exit /b 1
)

where "%FFPROBE%" >nul 2>&1
if errorlevel 1 (
    echo ERROR: could not find "%FFPROBE%".
    echo Edit the FFPROBE line near the top of this script to point at
    echo ffprobe.exe, or add ffmpeg's "bin" folder to your PATH.
    echo.
    pause
    exit /b 1
)

for %%F in (%*) do call :ProcessFile "%%~F"

echo.
echo All done.
pause
exit /b 0

:ProcessFile
set "SRC=%~1"

if not exist "%SRC%" (
    echo Skipping - not found: %SRC%
    goto :eof
)

echo Probing: %SRC%

rem cd into the video's own folder and hand ffprobe just the bare filename.
rem This avoids having to escape the ":" in a Windows drive letter, which
rem is a reserved character inside an ffmpeg filtergraph string.
pushd "%~dp1"
set "FN=%~nx1"
set "OUT=%~n1.txt"

"%FFPROBE%" -hide_banner -v error -f lavfi -i "movie='%FN%',signalstats,%SCDET_OPTS%" -show_frames -show_entries "frame=best_effort_timestamp_time:frame_tags=lavfi.signalstats.YMIN,lavfi.signalstats.YLOW,lavfi.signalstats.YAVG,lavfi.signalstats.YHIGH,lavfi.signalstats.YMAX,lavfi.signalstats.UMIN,lavfi.signalstats.ULOW,lavfi.signalstats.UAVG,lavfi.signalstats.UHIGH,lavfi.signalstats.UMAX,lavfi.signalstats.VMIN,lavfi.signalstats.VLOW,lavfi.signalstats.VAVG,lavfi.signalstats.VHIGH,lavfi.signalstats.VMAX,lavfi.signalstats.SATMIN,lavfi.signalstats.SATLOW,lavfi.signalstats.SATAVG,lavfi.signalstats.SATHIGH,lavfi.signalstats.SATMAX,lavfi.signalstats.HUEMED,lavfi.signalstats.HUEAVG,lavfi.signalstats.YDIF,lavfi.signalstats.UDIF,lavfi.signalstats.VDIF,lavfi.scd.mafd,lavfi.scd.score,lavfi.scd.time" -of "compact=print_section=0" > "%OUT%" 2>&1

if errorlevel 1 (
    echo   FAILED - see "%OUT%" for details
) else (
    echo   Saved: %OUT%
)

popd
goto :eof

It produces pretty big result files (~20 MB CSV - to be optimized - for a 1.5 GB 1080i50 AVC @ ~17 Mbps source file) so I've generated this Python script to analyze data and suggest possible filters configurations:

#!/usr/bin/env python3
"""
ffmpeg_levels_wb_analyzer.py

Parses a text/CSV dump of FFmpeg's `signalstats` + `scdet` filter metadata
(one pipe-delimited "key=value|key=value|..." record per frame, as produced
by e.g.:

    ffmpeg -i in.mkv -vf "signalstats,scdet,metadata=mode=print:file=stats.log" -f null -

or via `ffprobe -show_entries frame_tags -of default=nk=1:nw=1`) and computes:

  1. Suggested full-range (0-255) luma normalization points (black-in / white-in)
     for a linear stretch, at two confidence levels ("safe" and "aggressive").
  2. A gray-world white-balance correction (Cb/Cr recentering) based on the
     time-averaged chroma bias of the whole clip.
  3. Ready-to-use filter strings for BOTH:
       - FFmpeg (`lutyuv`, operating directly in YCbCr)
       - QSVEncC / vpp-QSVEnc (`--vpp-colorfix`, operating in RGB, per the
         QSVEncC option documentation: mode=manual with white<RRGGBB> /
         black<RRGGBB> per-channel input points)

Scene-cut frames (flagged by scdet score) are excluded from the aggregate
statistics by default, since transition frames carry unrepresentative
min/max/avg values.

Usage:
    python ffmpeg_levels_wb_analyzer.py stats.log
    python ffmpeg_levels_wb_analyzer.py stats.log --black-pct 2 --white-pct 2 --scd-threshold 0.15
    python ffmpeg_levels_wb_analyzer.py stats.log --matrix bt709 --json report.json
"""

import argparse
import json
import sys
from dataclasses import dataclass, field

import numpy as np

NEEDED_KEYS = [
    'best_effort_timestamp_time',
    'scd.score', 'scd.mafd',
    'signalstats.YMIN', 'signalstats.YLOW', 'signalstats.YAVG',
    'signalstats.YHIGH', 'signalstats.YMAX',
    'signalstats.UMIN', 'signalstats.UAVG', 'signalstats.UMAX',
    'signalstats.VMIN', 'signalstats.VAVG', 'signalstats.VMAX',
    'signalstats.SATAVG', 'signalstats.SATMAX',
    'signalstats.HUEAVG',
]

# BT.601 / BT.709 / BT.2020 YCbCr(8-bit, studio-derived coefficients) -> RGB
# constants (Kr, Kg_u, Kg_v, Kb), consistent with FFmpeg's / QSVEncC's own
# --vpp-colorspace matrix definitions.
MATRIX_COEFFS = {
    'bt601': (1.402, 0.344136, 0.714136, 1.772),
    'bt709': (1.5748, 0.187324, 0.468124, 1.8556),
    'bt2020': (1.4746, 0.164553, 0.571353, 1.8814),
}


def parse_line(line: str) -> dict:
    """Parse one pipe-delimited record into a flat {key: float} dict.
    Non-numeric / side-data fields are silently skipped."""
    d = {}
    for tok in line.strip().split('|'):
        if '=' not in tok:
            continue
        k, v = tok.split('=', 1)
        k = k.strip()
        if k.startswith('tag:lavfi.'):
            k = k[len('tag:lavfi.'):]
        elif k.startswith('side_datum') or k.startswith('side_data'):
            continue
        try:
            d[k] = float(v)
        except ValueError:
            continue
    return d


def load(path: str) -> dict:
    """Stream the file and collect per-field numpy arrays."""
    cols = {k: [] for k in NEEDED_KEYS}
    n_lines = 0
    n_parsed = 0
    with open(path, 'r', errors='replace') as f:
        for line in f:
            if not line.strip():
                continue
            n_lines += 1
            rec = parse_line(line)
            if 'signalstats.YAVG' not in rec:
                continue  # not a signalstats frame record
            n_parsed += 1
            for k in NEEDED_KEYS:
                cols[k].append(rec.get(k, np.nan))
    arrs = {k: np.array(v, dtype=float) for k, v in cols.items()}
    arrs['_n_lines'] = n_lines
    arrs['_n_parsed'] = n_parsed
    return arrs


def yuv_point_to_rgb(y: float, u: float, v: float, matrix: str) -> tuple:
    """Map a (Y, Cb, Cr) triple [0-255] to an (R, G, B) triple [0-255]
    using the given color matrix, clipped to the valid 8-bit range."""
    kr, kg_u, kg_v, kb = MATRIX_COEFFS[matrix]
    r = y + kr * (v - 128.0)
    g = y - kg_u * (u - 128.0) - kg_v * (v - 128.0)
    b = y + kb * (u - 128.0)
    return tuple(max(0.0, min(255.0, c)) for c in (r, g, b))


def rgb_to_hex(rgb: tuple) -> str:
    return ''.join(f'{round(c):02X}' for c in rgb)


@dataclass
class Report:
    n_frames_total: int
    n_frames_used: int
    n_scenecuts: int
    black_safe: float
    white_safe: float
    black_aggr: float
    white_aggr: float
    u_mean: float
    v_mean: float
    shift_u: float
    shift_v: float
    sat_mean: float
    hue_mean: float
    matrix: str
    # FFmpeg
    ffmpeg_levels_only: str
    ffmpeg_full: str
    ffmpeg_range_flag: str
    # QSVEncC / vpp-QSVEnc
    qsv_levels_only: str
    qsv_full: str
    qsv_range_flag: str
    notes: list = field(default_factory=list)


def analyze(arrs: dict, black_pct: float, white_pct: float,
            scd_threshold: float, matrix: str) -> Report:
    n_total = arrs['_n_parsed']

    scd = np.nan_to_num(arrs['scd.score'], nan=0.0)
    is_cut = scd >= scd_threshold
    n_cuts = int(is_cut.sum())
    keep = ~is_cut

    def col(name):
        a = arrs[name]
        return a[keep] if keep.sum() > 10 else a  # fall back if too few remain

    ymin, ylow, yavg, yhigh, ymax = (col('signalstats.YMIN'), col('signalstats.YLOW'),
                                      col('signalstats.YAVG'), col('signalstats.YHIGH'),
                                      col('signalstats.YMAX'))
    uavg, vavg = col('signalstats.UAVG'), col('signalstats.VAVG')
    satavg = col('signalstats.SATAVG')
    hueavg = col('signalstats.HUEAVG')

    # --- Luma normalization candidates ---------------------------------
    # "safe": based on the 10/90 percentile-per-frame series (YLOW/YHIGH),
    # further trimmed across time -> unlikely to clip legitimate shadow/highlight detail.
    black_safe = float(np.nanpercentile(ylow, black_pct))
    white_safe = float(np.nanpercentile(yhigh, 100 - white_pct))

    # "aggressive": based on true per-frame MIN/MAX, trimmed across time to
    # ignore isolated noise/flash frames -> uses more of the available range,
    # higher risk of clipping a few outlier pixels.
    black_aggr = float(np.nanpercentile(ymin, black_pct))
    white_aggr = float(np.nanpercentile(ymax, 100 - white_pct))

    black_safe = max(0.0, min(black_safe, 250.0))
    white_safe = max(black_safe + 5, min(white_safe, 255.0))
    black_aggr = max(0.0, min(black_aggr, 250.0))
    white_aggr = max(black_aggr + 5, min(white_aggr, 255.0))

    # --- White balance (gray-world on Cb/Cr) ----------------------------
    u_mean = float(np.nanmean(uavg))
    v_mean = float(np.nanmean(vavg))
    shift_u = 128.0 - u_mean
    shift_v = 128.0 - v_mean
    sat_mean = float(np.nanmean(satavg))
    hue_mean = float(np.nanmean(hueavg))

    notes = []
    if sat_mean > 40:
        notes.append(
            "Average saturation is fairly high (SATAVG mean = {:.1f}); the gray-world "
            "assumption behind the white-balance shift is less reliable on strongly "
            "monochromatic/colorful footage (e.g. dominant blue sky, green foliage, "
            "colored stage lighting). Verify visually before applying.".format(sat_mean)
        )
    if abs(shift_u) < 1 and abs(shift_v) < 1:
        notes.append("Measured chroma bias is negligible; white balance looks already neutral.")
    if n_cuts / max(n_total, 1) > 0.3:
        notes.append(
            "A large fraction of frames were flagged as scene cuts (threshold={:.2f}); "
            "consider raising --scd-threshold if this seems too aggressive.".format(scd_threshold)
        )
    if abs(black_aggr - 16) < 4 and abs(white_aggr - 235) < 4:
        notes.append(
            "Measured black/white extremes sit close to 16/235 -- this source may simply be "
            "legitimate limited-range (TV) content that is mistagged or needs a plain "
            "limited->full range flag conversion, rather than a custom data-driven stretch. "
            "See the *_range_flag commands below as a lighter-weight alternative."
        )

    # ---------------- FFmpeg (lutyuv, native YCbCr) ---------------------
    def lut_y(black, white):
        return f"clip((val-{black:.1f})*255/({white:.1f}-{black:.1f}),0,255)"

    def lut_c(shift):
        return f"clip(val+({shift:+.1f}),0,255)"

    ffmpeg_levels_only = f"lutyuv=y='{lut_y(black_safe, white_safe)}'"
    ffmpeg_full = (
        f"lutyuv=y='{lut_y(black_safe, white_safe)}':"
        f"u='{lut_c(shift_u)}':v='{lut_c(shift_v)}'"
    )
    ffmpeg_range_flag = "scale=in_range=limited:out_range=full,format=yuv420p"

    # ---------------- QSVEncC / vpp-QSVEnc (--vpp-colorfix, RGB) --------
    # colorfix's manual mode takes an explicit per-channel input black/white
    # point in RGB (white<RRGGBB> / black<RRGGBB>) and linearly stretches
    # each channel to 0-255 independently -- i.e. the same math as the
    # lutyuv expression above, just expressed post YCbCr->RGB conversion.
    # Levels-only: identical R=G=B hex -> pure luma stretch, no color shift.
    white_gray_hex = rgb_to_hex((white_safe, white_safe, white_safe))
    black_gray_hex = rgb_to_hex((black_safe, black_safe, black_safe))

    # Levels + white balance: evaluate the black/white luma points at the
    # clip's measured average chroma bias, so the correction simultaneously
    # stretches the range AND re-centers the color cast in one pass.
    white_rgb = yuv_point_to_rgb(white_safe, u_mean, v_mean, matrix)
    black_rgb = yuv_point_to_rgb(black_safe, u_mean, v_mean, matrix)
    white_full_hex = rgb_to_hex(white_rgb)
    black_full_hex = rgb_to_hex(black_rgb)

    qsv_levels_only = (
        f"--vpp-colorfix mode=manual,space=rgb,white={white_gray_hex},black={black_gray_hex}"
    )
    qsv_full = (
        f"--vpp-colorfix mode=manual,space=rgb,matrix={matrix},"
        f"white={white_full_hex},black={black_full_hex}"
    )
    qsv_range_flag = "--vpp-colorspace range=limited:full"

    return Report(
        n_frames_total=n_total,
        n_frames_used=int(keep.sum()),
        n_scenecuts=n_cuts,
        black_safe=black_safe, white_safe=white_safe,
        black_aggr=black_aggr, white_aggr=white_aggr,
        u_mean=u_mean, v_mean=v_mean,
        shift_u=shift_u, shift_v=shift_v,
        sat_mean=sat_mean, hue_mean=hue_mean,
        matrix=matrix,
        ffmpeg_levels_only=ffmpeg_levels_only,
        ffmpeg_full=ffmpeg_full,
        ffmpeg_range_flag=ffmpeg_range_flag,
        qsv_levels_only=qsv_levels_only,
        qsv_full=qsv_full,
        qsv_range_flag=qsv_range_flag,
        notes=notes,
    )


def print_report(r: Report, black_pct, white_pct, scd_threshold):
    print("=" * 74)
    print("FFmpeg signalstats/scdet analysis")
    print("=" * 74)
    print(f"Frames parsed        : {r.n_frames_total}")
    print(f"Scene cuts excluded  : {r.n_scenecuts} (scd.score >= {scd_threshold})")
    print(f"Frames used for stats: {r.n_frames_used}")
    print()
    print("-- Full-range luma normalization candidates (8-bit, 0-255) --")
    print(f"  SAFE       : black_in={r.black_safe:.1f}  white_in={r.white_safe:.1f}"
          f"   (from YLOW/YHIGH, {black_pct:.1f}/{100-white_pct:.1f} pct across time)")
    print(f"  AGGRESSIVE : black_in={r.black_aggr:.1f}  white_in={r.white_aggr:.1f}"
          f"   (from YMIN/YMAX, {black_pct:.1f}/{100-white_pct:.1f} pct across time)")
    print("  (filters below use the SAFE points; substitute the AGGRESSIVE pair for a stronger stretch)")
    print()
    print("-- White balance (gray-world Cb/Cr recentering) --")
    print(f"  Mean UAVG (Cb) = {r.u_mean:.2f}  ->  shift_u = {r.shift_u:+.2f}")
    print(f"  Mean VAVG (Cr) = {r.v_mean:.2f}  ->  shift_v = {r.shift_v:+.2f}")
    print(f"  Mean SATAVG    = {r.sat_mean:.2f}   Mean HUEAVG = {r.hue_mean:.1f} deg")
    print(f"  YCbCr->RGB matrix assumed for QSV RGB points: {r.matrix}")
    print()
    print("-- FFmpeg filters (-vf, native YCbCr via lutyuv) --")
    print("  Levels only:")
    print(f"    -vf \"{r.ffmpeg_levels_only}\"")
    print("  Levels + white balance (recommended, single pass):")
    print(f"    -vf \"{r.ffmpeg_full}\"")
    print("  Plain limited->full range-flag conversion (no custom stretch):")
    print(f"    -vf \"{r.ffmpeg_range_flag}\"")
    print()
    print("-- vpp-QSVEnc filters (QSVEncC, RGB via --vpp-colorfix) --")
    print("  Levels only:")
    print(f"    {r.qsv_levels_only}")
    print("  Levels + white balance (recommended, single pass):")
    print(f"    {r.qsv_full}")
    print("  Plain limited->full range-flag conversion (no custom stretch):")
    print(f"    {r.qsv_range_flag}")
    print()
    if r.notes:
        print("-- Notes --")
        for n in r.notes:
            print(f"  * {n}")
    print("=" * 74)
    print("Tip: re-run signalstats on the corrected output to confirm YMIN/YMAX")
    print("approach 0/255 and UAVG/VAVG approach 128 across the clip.")


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                  formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument('input', help='Path to the signalstats+scdet log/CSV file')
    ap.add_argument('--black-pct', type=float, default=2.0,
                     help='Percentile (across frames) used for the black point trim (default: 2)')
    ap.add_argument('--white-pct', type=float, default=2.0,
                     help='Percentile (across frames) used for the white point trim, '
                          'measured from the top (default: 2 -> 98th percentile)')
    ap.add_argument('--scd-threshold', type=float, default=0.15,
                     help='scd.score at/above which a frame is treated as a scene cut '
                          'and excluded from aggregate stats (default: 0.15)')
    ap.add_argument('--matrix', choices=['bt601', 'bt709', 'bt2020'], default='bt601',
                     help='YCbCr->RGB matrix assumed when deriving vpp-colorfix RGB '
                          'white/black points (default: bt601, typical for SD/legacy sources)')
    ap.add_argument('--json', metavar='FILE', default=None,
                     help='Optionally write the full report as JSON to FILE')
    args = ap.parse_args()

    arrs = load(args.input)
    if arrs['_n_parsed'] == 0:
        print("No signalstats records found in the input file.", file=sys.stderr)
        sys.exit(1)

    report = analyze(arrs, args.black_pct, args.white_pct, args.scd_threshold, args.matrix)
    print_report(report, args.black_pct, args.white_pct, args.scd_threshold)

    if args.json:
        with open(args.json, 'w') as f:
            json.dump(report.__dict__, f, indent=2)
        print(f"\nJSON report written to {args.json}")


if __name__ == '__main__':
    main()

Everything it's obviously just a rough imprecise draft but - conceptually - I believe that some measurements (and automatically calculated suggestions based on) could be useful for users.

Hope that inspires.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions