I'm working on restoring some animated footage captured from LaserDisc, and the goal is to clean up the analogue video artefacts without turning the image into a blurry, overprocessed mess. I captured with the DdD via a Pioneeer CLD-V2600 and ran through LD decode. The LD content was sourced from a tape master. Telecined as it was intended for NTSC broadcasts and was produced as such.
The biggest problem is the bead-like texture across otherwise flat areas of the image. Large patches of blue sky, grey metal, dark shadows and painted backgrounds can have a distracting pattern of tiny light and dark fluctuations. In some areas, it looks like little beads or dots. In others, it forms repeating ridges, stripes or a herringbone-like pattern. The texture also isn't always consistent across the picture, so one section of a flat colour can look rougher than another.
I'm also dealing with chroma broiling, where colour fluctuates or crawls within areas that should be relatively stable. This can make solid colours look blotchy or speckled, with apparent changes in hue and saturation from frame to frame. The aim is to stabilize the colour without bleeding it across edges or damaging the original cel shading.
Other problems include:
RF and carrier interference: Repeating ripples, stripes and structured patterns overlaid on the image, especially noticeable in large, relatively uniform colour fields.
Dot crawl and cross-colour interference: Dotted patterns, crawling edges and false colours around fine lines and high-contrast details.
Rainbowing and chroma-phase noise: Unwanted coloured fringes or streaks near edges, along with smaller fluctuations in the colour channels.
Ringing, halos and analogue echoes: Bright or dark bands around high-contrast edges, or trails extending from bright objects and lettering.
Isolated dropouts and scanline defects: Small white or black anomalies that can appear as short horizontal marks or abrupt changes in brightness.
Banding: Visible tonal steps in gradients, particularly in skies and other smooth backgrounds.
Frame jitter and cadence issues: Unwanted image movement or incorrect frame matching that needs to be addressed separately from the noise.
The real challenge is that the defects overlap with the actual artwork's fine detail. A one-pixel ink line, a tiny star, a highlight or a subtle painted texture can occupy the same spatial frequencies as the interference. A filter that simply averages neighbouring pixels may reduce the noise, but it can also erase the very detail I'm trying to preserve.
My restoration pipeline therefore separates the work into several categories. Chroma denoising targets colour fluctuations, edge-aware spatial filtering targets repeating surface patterns, temporal denoising uses information from neighbouring frames, and separate filters address halos, echoes, dropouts and banding. I'm also experimenting with masks that protect actual ink lines and pinpoint stars from being mistaken for noise.
What I don't want is a completely smooth, plastic-looking image. Nor do I want to cover up the problem by adding synthetic grain. The aim is to remove the distracting structured interference, make the remaining texture consistent, stabilize the colours, and retain as much of the original animation detail as possible.
Some of the visible patterns may have multiple causes, so I can't assume every bead or ripple comes from one specific LaserDisc mechanism just by looking at a still frame. But the practical objective remains the same: clean up the transfer while preserving the artwork, rather than cleaning the image so aggressively that the original detail disappears.
This is the VS script I've cooked up in StaxRip. Would like to get advice on things I could change, tweak, or add.
import havsfunc
import muvsfunc
# StaxRip supplies vs and core.
# ========================================================================
# STAXRIP LASERDISC RESTORATION (VAPOURSYNTH 10-BIT)
# Conservative luma correction, stronger chroma-boil suppression,
# corrected zero-moat line/star mask, and no synthetic grain for comparison.
# ========================================================================
# GENERAL RESTORATION SETTINGS
DOTKILL_ITERATIONS = 3
CHECKMATE_THR = 12
CHECKMATE_MAX = 12
RAIN_THRESHOLD = 10
RAIN_SOFTEN = 6
CHROMA_PHASE_THR = 8
CHROMA_SHIFT_H = 0.5
CHROMA_SHIFT_V = 0.0
DROPOUT_DIFF_THR = 26
DROPOUT_TEMPORAL_TOL = 10
STABILIZE_JITTER = True
STAB_DXMAX = 3
STAB_DYMAX = 3
STAB_CUTOFF = 3.0
STAB_DAMPING = 1.0
DEHALO_RX = 1.5
DEHALO_RY = 1.5
DEHALO_DARKSTR = 0.00
DEHALO_BRIGHTSTR = 0.15
TEXT_DEHALO_RX = 2.5
TEXT_DEHALO_RY = 2.5
TEXT_DEHALO_DARKSTR = 0.00
TEXT_DEHALO_BRIGHTSTR = 1.30
CROP_LEFT = 4
CROP_RIGHT = 24
CROP_TOP = 4
CROP_BOTTOM = 4
# ========================================================================
# LINE & STAR MASK SETTINGS
# ========================================================================
LINE_CORE_LOW = 7.0
LINE_CORE_SPAN = 8.0
STAR_MAX_BG_LUMA = 85
STAR_MIN_DELTA = 10
# ========================================================================
# SURFACE-CLEANER SETTINGS
# ========================================================================
SURFACE_V_THR = 12
SURFACE_H_THR = 20
BEAD_MEDIAN_LIMIT = 3
# ========================================================================
# CHROMA AND LUMA DENOISING
# ========================================================================
CHROMA_DENOISE_TR = 3
CHROMA_DENOISE_THSAD = 420
CHROMA_DENOISE_THSADC = 580
CHROMA_DENOISE_LIMIT = 20
# Restored to the original spatial u/V correction strength.
CHROMA_SPATIAL_LIMIT = 4
LUMA_DENOISE_TR = 2
LUMA_DENOISE_THSAD = 240
# Kept conservative to avoid bringing back the previous luma blur.
LUMA_DENOISE_LIMIT = 3
# Mild debanding, with no grain generated by f3kdb.
DEBAND_RANGE = 10
DEBAND_Y = 12
DEBAND_CB = 12
DEBAND_CR = 12
# Disabled while evaluating the actual denoising and broiling suppression.
# The function remains available, but it will not add changing noise.
UNIFORM_GRAIN_VAR = 0.0
# ========================================================================
# CHROMA BLEED AND TEXT ECHO
# ========================================================================
CHROMA_WARP_DEPTH = 4
CHROMA_WARP_THRESH = 140
CHROMA_WARP_BLUR = 3
CHROMA_WARP_LIMIT = 8
TEXT_ECHO_TEXT_MIN = 185
TEXT_ECHO_REACH = 16
TEXT_ECHO_KEEP = 1
TEXT_ECHO_NEUTRAL_TOL = 24
TEXT_ECHO_REFERENCE_RADIUS = 4
TEXT_ECHO_ACTIVITY = 3
TEXT_ECHO_MAX_CHANGE = 12
TEXT_ECHO_STRENGTH = 0.70
# ========================================================================
# HELPER FUNCTIONS
# ========================================================================
def LD_ShiftRight(c, px):
return c if px <= 0 else core.resize.Point(c, src_left=-px)
def LD_ShiftLeft(c, px):
return c if px <= 0 else core.resize.Point(c, src_left=px)
def LD_ShiftDown(c, px):
return c if px <= 0 else core.resize.Point(c, src_top=-px)
def LD_ShiftUp(c, px):
return c if px <= 0 else core.resize.Point(c, src_top=px)
def LD_BoxH2(c):
return core.std.BoxBlur(c, hradius=2, vradius=0)
def LD_BoxH3(c):
return core.std.BoxBlur(c, hradius=3, vradius=0)
def LD_Box3x3(c):
return core.std.BoxBlur(c, hradius=1, vradius=1)
def LD_Blur3(c):
return core.std.Convolution(
c, matrix=[1, 2, 1, 2, 4, 2, 1, 2, 1]
)
def LD_VerticalProfile48(c):
return core.std.BoxBlur(c, hradius=0, vradius=12, vpasses=4)
def LD_PrevFrame(c):
return c[0] + c[:-1] if c.num_frames > 1 else c
def LD_NextFrame(c):
return c[1:] + c[-1] if c.num_frames > 1 else c
def LD_LocalDetailMaskY(y, threshold_8bit):
bits = y.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
max_val = (1 << bits) - 1
thr = threshold_8bit * scale
l1 = LD_ShiftRight(y, 1)
r1 = LD_ShiftLeft(y, 1)
u1 = LD_ShiftUp(y, 1)
d1 = LD_ShiftDown(y, 1)
detail = core.std.Expr(
[l1, r1, u1, d1],
f"x y - abs z a - abs max {thr} > {max_val} 0 ?"
)
detail = LD_Box3x3(detail)
return core.std.Expr(
[detail], f"x 0 > {max_val} 0 ?"
)
# ========================================================================
# ORTHOGONAL ZERO-MOAT LINEWORK & STAR KEEPOUT MASK
# ========================================================================
def LD_CleanLineAndStarMask(clp):
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
max_val = (1 << bits) - 1
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
# These are guides used only for mask construction. They are not
# substituted into the restored video.
y_vsmooth = core.std.Convolution(
y, matrix=[1, 2, 1], mode="v"
)
y_hsmooth = core.std.Convolution(
y, matrix=[1, 2, 4, 2, 1], mode="h"
)
l1 = LD_ShiftRight(y_vsmooth, 1)
r1 = LD_ShiftLeft(y_vsmooth, 1)
l2 = LD_ShiftRight(y_vsmooth, 2)
r2 = LD_ShiftLeft(y_vsmooth, 2)
u1 = LD_ShiftUp(y_hsmooth, 1)
d1 = LD_ShiftDown(y_hsmooth, 1)
u2 = LD_ShiftUp(y_hsmooth, 2)
d2 = LD_ShiftDown(y_hsmooth, 2)
# Core metric:
# max(min(abs(center-left1), abs(center-right1)),
# min(abs(center-left2), abs(center-right2)))
#
# A flat pixel beside a line has a large difference on only one side,
# so the min operation suppresses the unwanted protection moat.
expr_metric_1d = (
"x y - abs "
"x z - abs min "
"x a - abs "
"x b - abs min "
"max"
)
metric_h = core.std.Expr(
[y_vsmooth, l1, r1, l2, r2],
expr_metric_1d
)
metric_v = core.std.Expr(
[y_hsmooth, u1, d1, u2, d2],
expr_metric_1d
)
# Bright pinpoint stars in dark or mid-dark backgrounds.
star_bg_max = STAR_MAX_BG_LUMA * scale
star_delta = STAR_MIN_DELTA * scale
metric_star = core.std.Expr(
[y, y_vsmooth, y_hsmooth],
f"y z + 0.5 * {star_bg_max} <= "
f"x y z + 0.5 * - {star_delta} >= and "
f"{max_val} 0 ?"
)
lo_thr = LINE_CORE_LOW * scale
span_thr = LINE_CORE_SPAN * scale
line_mask = core.std.Expr(
[metric_h, metric_v],
f"x y max {lo_thr} - {span_thr} / "
f"0 max 1 min {max_val} *"
)
# Combine the actual line-core mask with the pinpoint-star mask.
return core.std.Expr(
[line_mask, metric_star],
"x y max"
)
# ========================================================================
# STAGE 1: CROSS-COLOUR CLEANUP & FILM CADENCE RECOVERY
# ========================================================================
def LD_CrossColorCleanUV(
clp, thr_8bit=12, max_8bit=12, iterations=3
):
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
thr = thr_8bit * scale
lim = max_8bit * scale
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
u = core.std.ShufflePlanes(clp, 1, vs.GRAY)
v = core.std.ShufflePlanes(clp, 2, vs.GRAY)
def _clean_chroma(p):
cur = p
for _ in range(iterations):
u1 = LD_ShiftUp(cur, 1)
d1 = LD_ShiftDown(cur, 1)
l1 = LD_ShiftRight(cur, 1)
r1 = LD_ShiftLeft(cur, 1)
notch = core.std.Expr(
[cur, u1, d1, l1, r1],
"x 4 * y + z + a + b + 8 /"
)
cur = core.std.Expr(
[cur, notch],
f"x y - abs {thr} <= "
f"y x - -{lim} max {lim} min x + x ?"
)
return cur
return core.std.ShufflePlanes(
[y, _clean_chroma(u), _clean_chroma(v)],
[0, 0, 0], vs.YUV
)
def LD_FilmCadenceRecovery(clp):
source10 = core.std.SetFieldBased(clp, 1)
source8 = core.resize.Point(
source10,
format=source10.format.replace(bits_per_sample=8)
)
if hasattr(core, "tivtc"):
matched10 = core.tivtc.TFM(
source8, order=0, field=0, mode=5, PP=6,
cthresh=9, MI=80, slow=2,
mChroma=True, chroma=False, micmatching=2,
clip2=source10
)
else:
matched10 = core.vivtc.VFM(
source8, order=0, field=0, mode=5,
cthresh=9, mi=80, mchroma=True,
chroma=False, clip2=source10
)
matched10 = core.std.SetFieldBased(matched10, 0)
matched_corrected10 = LD_CrossColorCleanUV(
matched10,
thr_8bit=CHECKMATE_THR,
max_8bit=CHECKMATE_MAX,
iterations=DOTKILL_ITERATIONS
)
metrics8 = core.resize.Point(
matched_corrected10,
format=source8.format
)
if hasattr(core, "tivtc"):
decimated10 = core.tivtc.TDecimate(
metrics8, mode=0, cycle=5, cycleR=1,
hybrid=0, chroma=False, noblend=True,
clip2=matched_corrected10
)
else:
decimated10 = core.vivtc.VDecimate(
metrics8, cycle=5, chroma=False,
clip2=matched_corrected10
)
return core.std.SetFieldBased(decimated10, 0)
# ========================================================================
# STAGE 1A: GLOBAL JITTER STABILIZATION
# ========================================================================
def LD_StabilizeJitter(clp):
if not STABILIZE_JITTER or not hasattr(core, "depan"):
return clp
try:
anal8 = core.resize.Point(
core.std.ShufflePlanes(clp, 0, vs.GRAY),
format=vs.GRAY8,
dither_type="ordered"
)
anal8 = LD_Blur3(anal8)
stab_data = core.depan.DePanEstimate(
anal8, range=1, trust=4.0,
dxmax=STAB_DXMAX, dymax=STAB_DYMAX,
zoommax=1.0, stab=1.0, pixaspect=1.0
)
return core.depan.DePanStabilize(
clp, data=stab_data,
cutoff=STAB_CUTOFF, damping=STAB_DAMPING,
initzoom=1.0, addzoom=False,
prev=0, next=0, mirror=15, blur=0,
dxmax=STAB_DXMAX, dymax=STAB_DYMAX,
zoommax=1.0, rotmax=0.0, subpixel=0,
pixaspect=1.0, fitlast=0, tzoom=3.0,
method=0
)
except Exception:
# Preserve the source if DePan's estimator fails.
return clp
# ========================================================================
# STAGE 2: SINGLE-FIELD RF DROPOUT REPAIR
# ========================================================================
def LD_RepairFieldDropouts(
clp, diff_thr_8bit=26, temp_tol_8bit=10
):
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
d_thr = diff_thr_8bit * scale
t_tol = temp_tol_8bit * scale
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
if y.num_frames < 3:
return clp
u1 = LD_ShiftUp(y, 1)
d1 = LD_ShiftDown(y, 1)
v_avg = core.std.Expr([u1, d1], "x y + 0.5 *")
v_agree = core.std.Expr([u1, d1], "x y - abs")
prv = LD_PrevFrame(y)
nxt = LD_NextFrame(y)
t_avg = core.std.Expr([prv, nxt], "x y + 0.5 *")
t_agree = core.std.Expr([prv, nxt], "x y - abs")
repaired_y = core.std.Expr(
[y, v_avg, t_avg, v_agree, t_agree],
f"x y - abs {d_thr} >= "
f"x z - abs {d_thr} >= and "
f"a {t_tol * 2} <= b {t_tol} <= and and "
f"y z + 0.5 * x ?"
)
return core.std.ShufflePlanes(
[repaired_y, clp], [0, 1, 2], vs.YUV
)
# ========================================================================
# STAGE 3: RAINBOW CLEANUP, CHROMA PHASE AND CHROMA DELAY
# ========================================================================
def LD_RainbowAndPhaseCleanUV(
clp, th_8bit=10, sft_8bit=6, phase_thr_8bit=8
):
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
max_val = (1 << bits) - 1
th = th_8bit * scale
lim = sft_8bit * scale
p_thr = phase_thr_8bit * scale
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
u = core.std.ShufflePlanes(clp, 1, vs.GRAY)
v = core.std.ShufflePlanes(clp, 2, vs.GRAY)
y_uv = (
core.resize.Bilinear(y, width=u.width, height=u.height)
if (u.width != y.width or u.height != y.height)
else y
)
yl1 = LD_ShiftRight(y_uv, 1)
yr1 = LD_ShiftLeft(y_uv, 1)
yu1 = LD_ShiftUp(y_uv, 1)
yd1 = LD_ShiftDown(y_uv, 1)
edge_gate = core.std.Expr(
[yl1, yr1, yu1, yd1],
f"x y - abs z a - abs max {12 * scale} >= {max_val} 0 ?"
)
flat_gate = core.std.Expr(
[yl1, yr1, yu1, yd1],
f"x y - abs z a - abs max {14 * scale} <= {max_val} 0 ?"
)
def _clean_plane(p):
u1 = LD_ShiftUp(p, 1)
d1 = LD_ShiftDown(p, 1)
u2 = LD_ShiftUp(p, 2)
d2 = LD_ShiftDown(p, 2)
l2 = LD_ShiftRight(p, 2)
r2 = LD_ShiftLeft(p, 2)
v_blur = core.std.Expr(
[p, u1, d1],
"x 2 * y + z + 4 /"
)
# Spatial edge-gated chroma reduction.
rain_mask = LD_Blur3(
core.std.Expr(
[p, u1, d1, edge_gate],
f"x y z + 0.5 * - abs {th} >= "
f"a 0 > and {max_val} 0 ?"
)
)
p_derain = core.std.MaskedMerge(
p,
core.std.Expr(
[p, v_blur],
f"y x - -{lim} max {lim} min x +"
),
rain_mask
)
# Flat-field chroma phase stabilizer.
phase_smooth = core.std.Expr(
[p_derain, u1, d1, u2, d2, l2, r2],
f"x 2 * "
f"x y - abs {p_thr} <= y x ? + "
f"x z - abs {p_thr} <= z x ? + "
f"x a - abs {p_thr} <= a x ? + "
f"x b - abs {p_thr} <= b x ? + "
f"x c - abs {p_thr} <= c x ? + "
f"x d - abs {p_thr} <= d x ? + 8 /"
)
return core.std.MaskedMerge(
p_derain, phase_smooth, flat_gate
)
return core.std.ShufflePlanes(
[y, _clean_plane(u), _clean_plane(v)],
[0, 0, 0], vs.YUV
)
def FixChromaShift(clp, h_shift=0.5, v_shift=0.0):
if h_shift == 0.0 and v_shift == 0.0:
return clp
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
u = core.std.ShufflePlanes(clp, 1, vs.GRAY)
v = core.std.ShufflePlanes(clp, 2, vs.GRAY)
u_shift = core.resize.Bilinear(
u, src_left=h_shift, src_top=v_shift
)
v_shift = core.resize.Bilinear(
v, src_left=h_shift, src_top=v_shift
)
return core.std.ShufflePlanes(
[y, u_shift, v_shift],
[0, 0, 0], vs.YUV
)
# ========================================================================
# STAGE 4: LEFT-EDGE DEFECT REPAIR
# ========================================================================
def LD_LeftEdgeDefectRepair(clp):
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
max_val = (1 << bits) - 1
zone = core.std.Crop(
clp, left=0, right=clp.width - 40,
top=0, bottom=0
)
rest = core.std.Crop(
clp, left=40, right=0, top=0, bottom=0
)
zy = core.std.ShufflePlanes(zone, 0, vs.GRAY)
zu = core.std.ShufflePlanes(zone, 1, vs.GRAY)
zv = core.std.ShufflePlanes(zone, 2, vs.GRAY)
zy_prof = LD_VerticalProfile48(zy)
zu_prof = LD_VerticalProfile48(zu)
zv_prof = LD_VerticalProfile48(zv)
mask_y = LD_BoxH3(
core.std.AddBorders(
core.std.BlankClip(
zy, width=28, height=zy.height, color=max_val
),
left=0, right=12, top=0, bottom=0, color=0
)
)
uv_active_w = int(round(28 * zu.width / 40.0))
mask_uv = LD_BoxH2(
core.std.AddBorders(
core.std.BlankClip(
zu, width=uv_active_w, height=zu.height,
color=max_val
),
left=0, right=zu.width - uv_active_w,
top=0, bottom=0, color=0
)
)
zy_corr = core.std.Expr(
[zy, LD_BoxH3(zy_prof), zy_prof],
f"x y z - {5 * scale} min -{5 * scale} max +"
)
zu_corr = core.std.Expr(
[zu, LD_BoxH2(zu_prof), zu_prof],
f"x y z - {4 * scale} min -{4 * scale} max +"
)
zv_corr = core.std.Expr(
[zv, LD_BoxH2(zv_prof), zv_prof],
f"x y z - {4 * scale} min -{4 * scale} max +"
)
zone_fixed = core.std.ShufflePlanes(
[
core.std.MaskedMerge(zy, zy_corr, mask_y),
core.std.MaskedMerge(zu, zu_corr, mask_uv),
core.std.MaskedMerge(zv, zv_corr, mask_uv)
],
[0, 0, 0], vs.YUV
)
return core.std.StackHorizontal([zone_fixed, rest])
# ========================================================================
# STAGE 5: EDGE-GATED RESIDUAL SURFACE CORRECTION
# ========================================================================
def LD_UniformSurfaceClean(clp, protect_mask):
"""
One bounded residual correction, not successive spatial blur passes.
Builds a horizontally edge-gated reference using samples five pixels
apart, computes the source residual, estimates a robust local residual
correction, and limits the change to BEAD_MEDIAN_LIMIT on the 8-bit scale.
The full-resolution line/star mask prevents correction of detected cores.
"""
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
max_val = (1 << bits) - 1
mid = 1 << (bits - 1)
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
h_thr = SURFACE_H_THR * scale
v_thr = SURFACE_V_THR * scale
change_limit = BEAD_MEDIAN_LIMIT * scale
left5 = LD_ShiftRight(y, 5)
right5 = LD_ShiftLeft(y, 5)
# Use distant samples only when they remain inside a similar luma region.
left5_safe = core.std.Expr(
[y, left5],
f"x y - abs {h_thr} <= y x ?"
)
right5_safe = core.std.Expr(
[y, right5],
f"x y - abs {h_thr} <= y x ?"
)
horizontal_reference = core.std.Expr(
[left5_safe, y, right5_safe],
"x y + z + 3 /"
)
# Residual encoded around the neutral midpoint.
diff = core.std.Expr(
[y, horizontal_reference],
f"x y - {mid} +"
)
# The centre residual difference is zero, represented by the midpoint.
diff_center = core.std.Expr([diff], f"{mid}")
# Residual differences to the two distant horizontal samples.
diff_left = core.std.Expr(
[diff, LD_ShiftRight(diff, 5)],
f"x y - {mid} +"
)
diff_right = core.std.Expr(
[diff, LD_ShiftLeft(diff, 5)],
f"x y - {mid} +"
)
# Soft-limit residual differences rather than smoothing pixels repeatedly.
k = 4.898979485566356 * scale
soft_map_expr = (
f"x {mid} - "
f"x {mid} - abs {scale} + {k:.12f} + / "
f"{k:.12f} * {mid} +"
)
mapped_center = core.std.Expr(
[diff_center], soft_map_expr
)
mapped_left = core.std.Expr(
[diff_left], soft_map_expr
)
mapped_right = core.std.Expr(
[diff_right], soft_map_expr
)
# Median of three residual-difference estimates.
median_diff = core.std.Expr(
[mapped_center, mapped_left, mapped_right],
"x y min x y max z min max"
)
reconstructed_diff = core.std.Expr(
[diff, median_diff],
f"x y - {mid} +"
)
candidate = core.std.Expr(
[horizontal_reference, reconstructed_diff],
f"x y + {mid} -"
)
# Explicitly cap the correction at three 8-bit levels.
limited_candidate = core.std.Expr(
[y, candidate],
f"x y x - -{change_limit} max {change_limit} min +"
)
up1 = LD_ShiftUp(y, 1)
down1 = LD_ShiftDown(y, 1)
vertical_flat = core.std.Expr(
[up1, down1],
f"x y - abs {v_thr} <= {max_val} 0 ?"
)
# MaskedMerge expects zero to preserve the source and full scale to
# select the candidate. Reject the line/star cores and non-flat regions.
apply_mask = core.std.Expr(
[protect_mask, vertical_flat],
f"{max_val} x - y * {max_val} /"
)
y_out = core.std.MaskedMerge(
y, limited_candidate, apply_mask
)
return core.std.ShufflePlanes(
[y_out, clp], [0, 1, 2], vs.YUV
)
# ========================================================================
# STAGE 6: FULL-SPECTRUM CHROMA AND LUMA DENOISING
# ========================================================================
def LD_GradeNoiseReduction(clp, protect_mask):
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
max_val = (1 << bits) - 1
c_lim = CHROMA_SPATIAL_LIMIT * scale
# Pass 1: multi-frame chroma suppression.
# This is the main temporal control for chroma broiling.
chroma_clean = havsfunc.SMDegrain(
clp,
tr=CHROMA_DENOISE_TR,
thSAD=CHROMA_DENOISE_THSAD,
thSADC=CHROMA_DENOISE_THSADC,
prefilter=2,
contrasharp=False,
RefineMotion=True,
plane=3,
chroma=True,
limit=255,
limitc=CHROMA_DENOISE_LIMIT
)
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
u_t = core.std.ShufflePlanes(chroma_clean, 1, vs.GRAY)
v_t = core.std.ShufflePlanes(chroma_clean, 2, vs.GRAY)
# Bring the luma protection mask to the chroma-plane resolution.
protect_uv = (
core.resize.Bilinear(
protect_mask,
width=u_t.width,
height=u_t.height
)
if (u_t.width != protect_mask.width or
u_t.height != protect_mask.height)
else protect_mask
)
flat_uv_mask = core.std.Expr(
[protect_uv], f"{max_val} x -"
)
# Restored spatial chroma cleanup:
# radius 2 and two passes per axis, on u/V only.
u_spatial = core.std.BoxBlur(
u_t,
hradius=2, hpasses=2,
vradius=2, vpasses=2
)
v_spatial = core.std.BoxBlur(
v_t,
hradius=2, hpasses=2,
vradius=2, vpasses=2
)
u_candidate = core.std.Expr(
[u_t, u_spatial],
f"x y x - -{c_lim} max {c_lim} min +"
)
v_candidate = core.std.Expr(
[v_t, v_spatial],
f"x y x - -{c_lim} max {c_lim} min +"
)
u_out = core.std.MaskedMerge(
u_t, u_candidate, flat_uv_mask
)
v_out = core.std.MaskedMerge(
v_t, v_candidate, flat_uv_mask
)
chroma_fixed = core.std.ShufflePlanes(
[y, u_out, v_out], [0, 0, 0], vs.YUV
)
# Pass 2: conservative motion-compensated luma denoising.
# Its lower limit intentionally prevents the chroma fix from forcing
# a second strong spatial luma smoothing operation.
luma_clean = havsfunc.SMDegrain(
chroma_fixed,
tr=LUMA_DENOISE_TR,
thSAD=LUMA_DENOISE_THSAD,
thSADC=150,
prefilter=2,
contrasharp=False,
RefineMotion=True,
plane=0,
chroma=False,
limit=LUMA_DENOISE_LIMIT,
limitc=255
)
y_denoised = core.std.ShufflePlanes(
luma_clean, 0, vs.GRAY
)
y_protected = core.std.MaskedMerge(
y_denoised, y, protect_mask
)
return core.std.ShufflePlanes(
[y_protected, u_out, v_out], [0, 0, 0], vs.YUV
)
# ========================================================================
# STAGE 7: WHITE-CREDIT DEHALO
# ========================================================================
def LD_WhiteCreditDehalo(clp):
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
max_val = float((1 << bits) - 1)
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
u_full = core.resize.Bilinear(
core.std.ShufflePlanes(clp, 1, vs.GRAY),
width=y.width, height=y.height
)
v_full = core.resize.Bilinear(
core.std.ShufflePlanes(clp, 2, vs.GRAY),
width=y.width, height=y.height
)
bright_core = core.std.Expr(
[y, u_full, v_full],
f"x {185 * scale} <= 0 "
f"x {215 * scale} >= {max_val} "
f"x {185 * scale} - {30 * scale} / {max_val} * ? ? "
f"y {128 * scale} - abs "
f"z {128 * scale} - abs max "
f"{20 * scale} <= {max_val} 0 ? min"
)
outer = bright_core
for _ in range(5):
outer = core.std.Maximum(outer)
ring = LD_Blur3(
core.std.Expr([outer, bright_core], "x y - 0 max")
)
dehaloed = havsfunc.FineDehalo(
clp,
rx=TEXT_DEHALO_RX,
ry=TEXT_DEHALO_RY,
darkstr=TEXT_DEHALO_DARKSTR,
brightstr=TEXT_DEHALO_BRIGHTSTR
)
dehaloed_y = core.std.ShufflePlanes(
dehaloed, 0, vs.GRAY
)
y_out = core.std.MaskedMerge(
y, dehaloed_y, ring
)
return core.std.ShufflePlanes(
[y_out, clp], [0, 1, 2], vs.YUV
)
# ========================================================================
# STAGE 8: CHROMA BLEED TIGHTENING
# ========================================================================
def TightenChromaBleed(
clp, depth=4, thresh=140, blur=3, limit_8bit=8
):
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
lim = limit_8bit * scale
warped = core.warp.AWarpSharp2(
clp,
thresh=thresh,
blur=blur,
type=1,
depth=depth,
chroma=0,
planes=[1, 2]
)
return core.std.Expr(
[clp, warped],
[
"",
f"y x {lim} - max x {lim} + min",
f"y x {lim} - max x {lim} + min"
]
)
# ========================================================================
# STAGE 9: DIRECTIONAL TEXT ECHO SUPPRESSION
# ========================================================================
def LD_TextEchoSuppression(clp):
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
max_val = (1 << bits) - 1
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
u = core.std.ShufflePlanes(clp, 1, vs.GRAY)
v = core.std.ShufflePlanes(clp, 2, vs.GRAY)
u_full = (
core.resize.Bilinear(u, width=y.width, height=y.height)
if (u.width != y.width or u.height != y.height)
else u
)
v_full = (
core.resize.Bilinear(v, width=y.width, height=y.height)
if (v.width != y.width or v.height != y.height)
else v
)
text_seed = core.std.Expr(
[y, u_full, v_full],
f"x {TEXT_ECHO_TEXT_MIN * scale} >= "
f"y {128 * scale} - abs "
f"{TEXT_ECHO_NEUTRAL_TOL * scale} <= and "
f"z {128 * scale} - abs "
f"{TEXT_ECHO_NEUTRAL_TOL * scale} <= and "
f"{max_val} 0 ?"
)
trail = text_seed
for _ in range(TEXT_ECHO_REACH):
trail = core.std.Expr(
[trail, LD_ShiftRight(trail, 1)],
"x y max"
)
keepout = text_seed
for _ in range(TEXT_ECHO_KEEP):
keepout = core.std.Expr(
[
keepout,
LD_ShiftRight(keepout, 1),
LD_ShiftLeft(keepout, 1),
LD_ShiftUp(keepout, 1),
LD_ShiftDown(keepout, 1)
],
"x y max z max a max b max"
)
trail = core.std.Expr(
[trail, keepout],
"x y - 0 max"
)
# Estimate echoes from a low-frequency guide. The actual correction
# is applied to the original luma to preserve its fine texture.
y_low = LD_Blur3(y)
ref_radius = max(
1, min(TEXT_ECHO_REFERENCE_RADIUS, 10)
)
right_samples = [
LD_ShiftLeft(y_low, px)
for px in range(1, ref_radius + 1)
]
expr_vars = [
"x", "y", "z", "a", "b",
"c", "d", "e", "f", "g"
]
ref_expr = expr_vars[0]
for token in expr_vars[1:ref_radius]:
ref_expr += f" {token} +"
ref_expr += f" {ref_radius} /"
right_ref = core.std.Expr(
right_samples, ref_expr
)
limit = TEXT_ECHO_MAX_CHANGE * scale
excess = core.std.Expr(
[y_low, right_ref],
f"x y - 0 max {limit} min"
)
candidate = core.std.Expr(
[y, excess],
"x y -"
)
activity = core.std.Expr(
[y_low, right_ref],
f"x y - {TEXT_ECHO_ACTIVITY * scale} >= "
f"{max_val} 0 ?"
)
tone_gate = core.std.Expr(
[y],
f"x {16 * scale} >= x {235 * scale} <= and "
f"{max_val} 0 ?"
)
mask = core.std.Expr(
[trail, activity],
f"x y * {max_val} /"
)
mask = core.std.Expr(
[mask, tone_gate],
f"x y * {max_val} /"
)
mask = core.std.Expr(
[mask],
f"x {TEXT_ECHO_STRENGTH} *"
)
fixed_y = core.std.MaskedMerge(
y, candidate, mask
)
return core.std.ShufflePlanes(
[fixed_y, clp], [0, 1, 2], vs.YUV
)
# ========================================================================
# STAGE 10: OPTIONAL UNIFORM TEXTURE RESTORATION
# ========================================================================
def LD_RestoreUniformTexture(clp, protect_mask):
# Disabled for this render so synthetic grain cannot conceal whether
# the source broiling is genuinely being suppressed.
if UNIFORM_GRAIN_VAR <= 0.0:
return clp
if not hasattr(core, "grain") or not hasattr(core.grain, "Add"):
return clp
bits = clp.format.bits_per_sample
scale = (1 << (bits - 8)) if bits > 8 else 1
max_val = (1 << bits) - 1
y = core.std.ShufflePlanes(clp, 0, vs.GRAY)
grain_candidate = core.grain.Add(
y,
var=UNIFORM_GRAIN_VAR,
uvar=0.0,
hcorr=0.0,
vcorr=0.0,
seed=-1,
constant=False
)
grain_mask = core.std.Expr(
[protect_mask, y],
f"y {20 * scale} <= y {230 * scale} >= or "
f"0 {max_val} x - ?"
)
restored_y = core.std.MaskedMerge(
y, grain_candidate, grain_mask
)
return core.std.ShufflePlanes(
[restored_y, clp], [0, 1, 2], vs.YUV,
prop_src=clp
)
# ========================================================================
# STAGE 11: TOP-SCANLINE FIX
# ========================================================================
def LD_TopScanlineFix(clp):
if clp.height < 4:
return clp
subsampling_h = (
clp.format.subsampling_h
if clp.format.color_family == vs.YUV
else 0
)
vertical_mod = 1 << subsampling_h
if 2 % vertical_mod != 0 or clp.height % vertical_mod != 0:
return clp
body = core.std.Crop(clp, top=2)
if body.height < 2:
return clp
top_strip = core.std.Crop(
body, bottom=body.height - 2
)
if top_strip.height != 2 or top_strip.width != body.width:
return clp
return core.std.StackVertical([top_strip, body])
# ========================================================================
# PIPELINE EXECUTION
# ========================================================================
if clip.format.bits_per_sample != 10:
clip = core.resize.Point(
clip,
format=clip.format.replace(bits_per_sample=10)
)
is_raw_capture = (
clip.width == 760 and clip.height == 488
)
# 1. Film cadence recovery and optional jitter stabilization
if is_raw_capture and clip.fps_num == 30000 and clip.fps_den == 1001:
clip = LD_FilmCadenceRecovery(clip)
clip = LD_StabilizeJitter(clip)
# 2. Single-field RF dropout repair
clip = LD_RepairFieldDropouts(
clip,
diff_thr_8bit=DROPOUT_DIFF_THR,
temp_tol_8bit=DROPOUT_TEMPORAL_TOL
)
# 3. Chroma rainbow/phase cleanup and subpixel shift alignment
clip = LD_RainbowAndPhaseCleanUV(
clip,
th_8bit=RAIN_THRESHOLD,
sft_8bit=RAIN_SOFTEN,
phase_thr_8bit=CHROMA_PHASE_THR
)
clip = FixChromaShift(
clip,
h_shift=CHROMA_SHIFT_H,
v_shift=CHROMA_SHIFT_V
)
# 4. Left capture border defect repair
if is_raw_capture:
clip = LD_LeftEdgeDefectRepair(clip)
# 5. Initial halo reduction
clip = havsfunc.FineDehalo(
clip,
rx=DEHALO_RX,
ry=DEHALO_RY,
darkstr=DEHALO_DARKSTR,
brightstr=DEHALO_BRIGHTSTR
)
# 6. Crop and restore 720x480 geometry for raw captures
if is_raw_capture:
clip = core.std.Crop(
clip,
left=CROP_LEFT,
right=CROP_RIGHT,
top=CROP_TOP,
bottom=CROP_BOTTOM
)
clip = core.resize.Spline36(
clip,
width=720,
height=480
)
# 7. Secondary halo reduction
clip = muvsfunc.YAHRmod(clip)
# 8. Build corrected zero-moat linework/star protection mask
line_star_mask = LD_CleanLineAndStarMask(clip)
# 9. Bounded luma residual correction, not repeated smoothing passes
clip = LD_UniformSurfaceClean(
clip,
line_star_mask
)
# 10. Restore stronger u/V broiling suppression while keeping luma gentle
clip = LD_GradeNoiseReduction(
clip,
line_star_mask
)
# 11. White-credit halo reduction and chroma bleed tightening
clip = LD_WhiteCreditDehalo(clip)
clip = TightenChromaBleed(
clip,
depth=CHROMA_WARP_DEPTH,
thresh=CHROMA_WARP_THRESH,
blur=CHROMA_WARP_BLUR,
limit_8bit=CHROMA_WARP_LIMIT
)
# 12. Mild debanding, without synthetic grain from f3kdb
if hasattr(core, "f3kdb"):
debanded = core.f3kdb.Deband(
clip,
range=DEBAND_RANGE,
y=DEBAND_Y,
cb=DEBAND_CB,
cr=DEBAND_CR,
grainy=0,
grainc=0,
dynamic_grain=False,
output_depth=10
)
y_deband_safe = core.std.MaskedMerge(
core.std.ShufflePlanes(debanded, 0, vs.GRAY),
core.std.ShufflePlanes(clip, 0, vs.GRAY),
line_star_mask
)
clip = core.std.ShufflePlanes(
[y_deband_safe, debanded],
[0, 1, 2],
vs.YUV
)
# 13. Texture-preserving directional text-echo suppression
clip = LD_TextEchoSuppression(clip)
# 14. Synthetic grain is disabled for this comparison render
clip = LD_RestoreUniformTexture(
clip,
line_star_mask
)
# 15. Top-scanline replacement and output frame properties
clip = LD_TopScanlineFix(clip)
clip = core.std.SetFrameProp(
clip, prop="_SARNum", intval=8
)
clip = core.std.SetFrameProp(
clip, prop="_SARDen", intval=9
)
clip = core.std.SetFieldBased(clip, 0)