ifb
23rd July 2026, 03:46
Perceptually-optimal progressive to interlaced conversion for VapourSynth and AviSynth+
Binaries (Windows/Linux/Mac) and the full writeup at https://github.com/ifb/vapoursynth-interlace.
Don't just discard alternate lines (or apply an arbitrary blur first). This plugin implements the actual BBC R&D algorithm for doing it right: WHP 315 ("Optimal Interlacing using Human Sensitivity Measurements", K.C. Noland, 2016) - a 19-line by 5-frame vertical-temporal FIR low-pass applied to the progressive source before the field lines are discarded, with a pass-band shaped by measured human sensitivity to interlacing aliasing artifacts from WHP 230. Output has been validated against the official EBU TECH 3384 test patterns and measurement methodology.
What it does
2N progressive frames in and N woven interlaced frames out, at half rate. Sets _FieldBased and doubles frame duration for you.
Applies the WHP 315 vertical-temporal prefilter so the discarded lines' energy is folded back in properly instead of just aliasing.
Same filter is applied consistently across all planes (per WHP 230 - zero phase, so no chroma/luma timing shift).
Handles 4:2:0 chroma siting correctly: interlaced 4:2:0 sites chroma at different vertical positions than progressive 4:2:0, and this filter resamples chroma onto the correct interlaced siting grid as part of the same convolution.
8–16 bit integer, 16-bit and 32-bit float.
Bit-exact: integer output is bit-identical across the C reference and every SIMD path (SSE2 / AVX2 / AVX-512, runtime dispatch). Float paths use FMA on AVX2+ so they can differ from the C path by a few ULP (more accurate, not less).
What it deliberately does not do
No spatial resizing, no motion compensation. This is only the vertical-temporal interlacing step. Scaling is zimg's job (core.resize), and WHP 315 §3 rejects motion compensation as unnecessary for this problem. Do all your scaling, color conversion and tone-mapping in the progressive domain, then interlace last.
Usage
clip = core.interlace.Interlace(clip, tff=True, filter=True)
Same call signature in AviSynth+:
Interlace(tff=true, filter=true)
# 1080p50 -> 1080i25, staying 4:2:0
out = core.interlace.Interlace(clip)
# UHD 2160p50 -> 1080i25
c = core.resize.Lanczos(clip, 1920, 1080, format=vs.YUV422P10, filter_param_a=4)
out = core.interlace.Interlace(c)
# 1080p50 -> 576i25 (PAL SD, anamorphic 16:9)
c = core.resize.Lanczos(clip, 704, 576, format=vs.YUV420P8, filter_param_a=4)
c = core.std.AddBorders(c, left=8, right=8, color=[16, 128, 128])
out = core.interlace.Interlace(c)
Binaries (Windows/Linux/Mac) and the full writeup at https://github.com/ifb/vapoursynth-interlace.
Don't just discard alternate lines (or apply an arbitrary blur first). This plugin implements the actual BBC R&D algorithm for doing it right: WHP 315 ("Optimal Interlacing using Human Sensitivity Measurements", K.C. Noland, 2016) - a 19-line by 5-frame vertical-temporal FIR low-pass applied to the progressive source before the field lines are discarded, with a pass-band shaped by measured human sensitivity to interlacing aliasing artifacts from WHP 230. Output has been validated against the official EBU TECH 3384 test patterns and measurement methodology.
What it does
2N progressive frames in and N woven interlaced frames out, at half rate. Sets _FieldBased and doubles frame duration for you.
Applies the WHP 315 vertical-temporal prefilter so the discarded lines' energy is folded back in properly instead of just aliasing.
Same filter is applied consistently across all planes (per WHP 230 - zero phase, so no chroma/luma timing shift).
Handles 4:2:0 chroma siting correctly: interlaced 4:2:0 sites chroma at different vertical positions than progressive 4:2:0, and this filter resamples chroma onto the correct interlaced siting grid as part of the same convolution.
8–16 bit integer, 16-bit and 32-bit float.
Bit-exact: integer output is bit-identical across the C reference and every SIMD path (SSE2 / AVX2 / AVX-512, runtime dispatch). Float paths use FMA on AVX2+ so they can differ from the C path by a few ULP (more accurate, not less).
What it deliberately does not do
No spatial resizing, no motion compensation. This is only the vertical-temporal interlacing step. Scaling is zimg's job (core.resize), and WHP 315 §3 rejects motion compensation as unnecessary for this problem. Do all your scaling, color conversion and tone-mapping in the progressive domain, then interlace last.
Usage
clip = core.interlace.Interlace(clip, tff=True, filter=True)
Same call signature in AviSynth+:
Interlace(tff=true, filter=true)
# 1080p50 -> 1080i25, staying 4:2:0
out = core.interlace.Interlace(clip)
# UHD 2160p50 -> 1080i25
c = core.resize.Lanczos(clip, 1920, 1080, format=vs.YUV422P10, filter_param_a=4)
out = core.interlace.Interlace(c)
# 1080p50 -> 576i25 (PAL SD, anamorphic 16:9)
c = core.resize.Lanczos(clip, 704, 576, format=vs.YUV420P8, filter_param_a=4)
c = core.std.AddBorders(c, left=8, right=8, color=[16, 128, 128])
out = core.interlace.Interlace(c)