View Full Version : vapoursynth-composite


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

Blue_MiSfit
24th July 2026, 08:25
What a cool idea :)

Selur
25th July 2026, 22:37
Using composite.Restore on a clip from videohelp[/code] with:
# restoration using Composite
clip = core.composite.Restore(clip, standard="ntsc")

# Field matching using TFM
clip = core.tivtc.TFM(clip)
if adds some check patterns:
[url=https://ibb.co/MDjYwTJq]https://i.ibb.co/svD0xSXL/grafik.png (https://forum.videohelp.com/threads/420895-VHS-deinterlacing-issue)
Checkmate can remove those, but is this as expected?

Cu Selur

ifb
26th July 2026, 23:19
The checkerboard pattern is chroma leaking into luma (i.e. subcarrier that wasn't removed from luma). If you use Encode() by itself, you'll see what I mean.

The 3D transform decoding modes rely on exact subcarrier phase relationships between fields. Degenerate stuff like VHS should use 2D decoding (dimensions=2). SeparateFields() shows that this clip isn't truly interlaced either.

c = core.bs.VideoSource("yuma scene 3 test.avi")
c = core.std.SetFieldBased(c, 2)
c = core.composite.Restore(c, standard="ntsc", dimensions=2)

Selur
27th July 2026, 03:34
Thanks, I'll look at that.
Would be nice if the README.md would show the whole signatures (https://github.com/ifb/vapoursynth-composite/blob/c0efa536204acd51dd7327357bd00e6b83e933d8/src/composite.c#L1152) with defaults&co, so that it's easier to understand what options there are. :)

Cu Selur

hydra3333
28th July 2026, 10:31
Thank you ! Looking forward to trying it.

Adub
2nd August 2026, 18:48
+1 to proper docs in the README. Maybe also give some examples like the VHS 2D processing exception you listed above.

ifb
14th August 2026, 05:45
+1 to proper docs in the README. Maybe also give some examples like the VHS 2D processing exception you listed above.

README is updated.

I was wrong or explained this poorly. For this particular case, the fields are out of phase, so the motion between fields isn't how the 3D transform expects. Dropping to 2D (as I suggested) eliminates most of the leftover chroma-in-luma checkerboarding, but doing field matching first and keeping 3D is marginally better (honestly, neither does very well on this clip):

c = core.bs.VideoSource("yuma.avi")
c = core.std.SetFieldBased(c, 2)
c = core.tivtc.TFM(c)
c = core.composite.Restore(c, standard="ntsc")

This is not general advice. For normal interlaced or correctly telecined material (2:3 pulldown DVD, broadcast capture) the interlaced signal is what the original decoder saw, and that's what the round trip through Restore() means to reproduce - so run Restore() first, field-match after.

I'm not sure VHS actually has much to do with anything. I did test with Hi8 (which is color under like VHS) and had excellent results. It seems the further you get away from the original composite decoding and field structure, the less effective this plugin is. At that point, checkmate or whatever else is probably better.