ifb
23rd July 2026, 05:20
NTSC/PAL encode and comb/Transform decode for cross-color and cross-luma removal
GitHub: https://github.com/ifb/vapoursynth-composite
Cross-color (rainbows) and cross-luma (dot crawl) artifacts on composite-sourced material are an artifact of a specific bad Y/C separation at decode time. Filtering the resulting YCbCr after the fact means working against a nonlinear, signal-dependent distortion with a denoiser or a rainbow suppressor, which trades detail for artifact removal because it has no model of what produced the artifact.
Approach
Rather than filtering the artifact in YCbCr, the plugin re-encodes the clip to the composite (mono) signal a period decoder would have received - accurate NTSC/PAL modulation, correct subcarrier phase per field/frame - then redecodes it with a proper separator: 2D/3D adaptive line comb, a Transform (frequency-domain) separator, or a motion-routed hybrid of the two for NTSC. Because the encode step is a faithful forward model of the composite signal, redoing the decode correctly means the separation errors are not introduced in the first place, rather than being detected and patched afterward.
import vapoursynth as vs
core = vs.core
clip = core.bs.VideoSource("tape.mkv")
out = core.composite.Restore(clip, standard="ntsc")
out.set_output()
BSVideoSource("tape.mkv")
composite_Restore(standard="ntsc")
Restore does everything in one call. Encode/Decode are exposed separately for anyone working with composite signals directly.
Input constraints
Constant-format YUV, any bit depth/subsampling, any width (resampled internally, output width configurable). Height is fixed by raster geometry: PAL is 576 lines exactly; NTSC is 480 or 486, with a 480-line clip placed on the 486-line raster by field order (BFF: rows 4–483, TFF/RP 202: rows 5–484).
Measurements
Methodology: clean source > simulated bad decoder (degraded) > this plugin > compared back to the clean source, via PSNR/XPSNR/SSIMULACRA2 plus two artifact-specific metrics - chromaHF (residual chroma high-frequency energy, i.e. rainbow) and flicker (frame-to-frame chroma delta, i.e. crawl), both lower-is-better. BT.802 is the corpus the tables were tuned on. The held-out VQEG numbers and the tuned BT.802-motion numbers track closely (chromaHF −73% vs. −75%, flicker −82% vs. −73%).
NTSC (defaults, real footage): chromaHF reduced ~73–75%, flicker ~73–82% vs. degraded.
PAL (defaults, held-out): chromaHF reduced ~43%, flicker ~26%; evidence=1.0 pushes both slightly further.
Full tables broken out by corpus and decoder mode (comb vs. Transform vs. hybrid, 2D vs. 3D) are in the README, along with the metrics scripts.
refine (Restore-only luma pass)
Restore has an optional Y-only post-decode step, refine (default=1). It is not unsharp masking - no edge detection, no local contrast boost. Since the encode step models the forward composite chain, refine runs N iterations of Landweber-style constrained deconvolution: propose a sharper luma, push it back through the modeled encode/decode chain, compare to the actually-decoded luma, keep only the correction consistent with the observed signal. Measured effect on detailed footage: increases SSIMULACRA2/PSNR/XPSNR toward the clean reference, and the recovered high-frequency content survives a downstream sharpener rather than being redundant with one - behavior consistent with recovered detail rather than added ringing.
Gain is proportional to how closely the source decoder matches the modeled notch (soft/heavily processed sources see less benefit), and on grain-dominant sources (VHS/Hi8-class) some of the recovered high frequency is amplified grain rather than picture detail - set refine=0 there, or on flat/graphic content (titles, bars, test patterns) where it can overreach. It only exists inside Restore, since it depends on the pre-encode picture.
Diagnostics
frameprops are exposed for later analysis: separation confidence (mean/stddev, eq=2 path), motion fraction (NTSC hybrid), refine residual/correction. Also two mask outputs for masked postprocessing: mask="motion" (NTSC hybrid routing, white = motion-decoded) and mask="confidence" (separation confidence). VapourSynth returns [pic, mask]; AviSynth+ gets it as YUVA alpha.
NTSC example:
https://compare.promptingpixels.com/a/07mHb8F
GitHub: https://github.com/ifb/vapoursynth-composite
Cross-color (rainbows) and cross-luma (dot crawl) artifacts on composite-sourced material are an artifact of a specific bad Y/C separation at decode time. Filtering the resulting YCbCr after the fact means working against a nonlinear, signal-dependent distortion with a denoiser or a rainbow suppressor, which trades detail for artifact removal because it has no model of what produced the artifact.
Approach
Rather than filtering the artifact in YCbCr, the plugin re-encodes the clip to the composite (mono) signal a period decoder would have received - accurate NTSC/PAL modulation, correct subcarrier phase per field/frame - then redecodes it with a proper separator: 2D/3D adaptive line comb, a Transform (frequency-domain) separator, or a motion-routed hybrid of the two for NTSC. Because the encode step is a faithful forward model of the composite signal, redoing the decode correctly means the separation errors are not introduced in the first place, rather than being detected and patched afterward.
import vapoursynth as vs
core = vs.core
clip = core.bs.VideoSource("tape.mkv")
out = core.composite.Restore(clip, standard="ntsc")
out.set_output()
BSVideoSource("tape.mkv")
composite_Restore(standard="ntsc")
Restore does everything in one call. Encode/Decode are exposed separately for anyone working with composite signals directly.
Input constraints
Constant-format YUV, any bit depth/subsampling, any width (resampled internally, output width configurable). Height is fixed by raster geometry: PAL is 576 lines exactly; NTSC is 480 or 486, with a 480-line clip placed on the 486-line raster by field order (BFF: rows 4–483, TFF/RP 202: rows 5–484).
Measurements
Methodology: clean source > simulated bad decoder (degraded) > this plugin > compared back to the clean source, via PSNR/XPSNR/SSIMULACRA2 plus two artifact-specific metrics - chromaHF (residual chroma high-frequency energy, i.e. rainbow) and flicker (frame-to-frame chroma delta, i.e. crawl), both lower-is-better. BT.802 is the corpus the tables were tuned on. The held-out VQEG numbers and the tuned BT.802-motion numbers track closely (chromaHF −73% vs. −75%, flicker −82% vs. −73%).
NTSC (defaults, real footage): chromaHF reduced ~73–75%, flicker ~73–82% vs. degraded.
PAL (defaults, held-out): chromaHF reduced ~43%, flicker ~26%; evidence=1.0 pushes both slightly further.
Full tables broken out by corpus and decoder mode (comb vs. Transform vs. hybrid, 2D vs. 3D) are in the README, along with the metrics scripts.
refine (Restore-only luma pass)
Restore has an optional Y-only post-decode step, refine (default=1). It is not unsharp masking - no edge detection, no local contrast boost. Since the encode step models the forward composite chain, refine runs N iterations of Landweber-style constrained deconvolution: propose a sharper luma, push it back through the modeled encode/decode chain, compare to the actually-decoded luma, keep only the correction consistent with the observed signal. Measured effect on detailed footage: increases SSIMULACRA2/PSNR/XPSNR toward the clean reference, and the recovered high-frequency content survives a downstream sharpener rather than being redundant with one - behavior consistent with recovered detail rather than added ringing.
Gain is proportional to how closely the source decoder matches the modeled notch (soft/heavily processed sources see less benefit), and on grain-dominant sources (VHS/Hi8-class) some of the recovered high frequency is amplified grain rather than picture detail - set refine=0 there, or on flat/graphic content (titles, bars, test patterns) where it can overreach. It only exists inside Restore, since it depends on the pre-encode picture.
Diagnostics
frameprops are exposed for later analysis: separation confidence (mean/stddev, eq=2 path), motion fraction (NTSC hybrid), refine residual/correction. Also two mask outputs for masked postprocessing: mask="motion" (NTSC hybrid routing, white = motion-decoded) and mask="confidence" (separation confidence). VapourSynth returns [pic, mask]; AviSynth+ gets it as YUVA alpha.
NTSC example:
https://compare.promptingpixels.com/a/07mHb8F