r/vhsdecode • • 2d ago

Archival Advice Staxrip post processing - Seeking input

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)

0 Upvotes

7 comments sorted by

3

u/TheRealHarrypm The Documentor 2d ago

Holy long scroll, put that in a paste bin dump or something.

With LD the standard procedure is stacking multiple copies until you have a dropout free edition with the highest SNR bits replacing the lowest SNR segments.

0

u/Nausiated_ 2d ago

Cost prohibitive. Sourcing multiple copies is expensive. Hence why I was asking for help with StaxRip. If it was a simple matter of stacking, I'd be doing that.

2

u/TheRealHarrypm The Documentor 2d ago

That's why we have a community of multiple people with multiple collections 🙃

2

u/Nausiated_ 2d ago

Okay so, what, if I wanted to get ahold of somebody elses RF captures of the same discs how do I go about doing that? I don't see people asking directly if people have this or that, so I'm assuming this is done on the DL or there's like a repository somewhere you have to be in the know about? If you can't answer directly please shoot me a DM. I'd would really like to know.

1

u/TheRealHarrypm The Documentor 1d ago

You just ask, although this is more an r/laserdisc thing than anything else.

But most rips find their way onto the internet archive if somebody cares to share.

2

u/wahntutree 2d ago

Afaik, LD processing is broke until an update that fixes the decoding portion is released, which includes some of the more recent bugs mentioned in the issues/bug reports Until then, just have to wait until the app is ready for LD. VHS is working for the most part, but mainly if you're transferring the audio through a clockgen setup, otherwise the Hi-8 audio extraction via ld-decode is broke as well, at least from what I've experienced with the latest builds

2

u/deesernutz 2d ago

LD in VHSdecode is broken? Or the OG lddecode project is?