@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.
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:
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:
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.