View Full Version : Expr() – Predictive Ringing Removal


geometer
5th April 2026, 15:20
This is a fairly short function that targets ringing or echo-artifacts from sharp edges. It does not blur, and also seeks to protect little image structures close to the size of the artifacts.

Here is the most recent update from Aug, 2026: https://forum.doom9.org/showthread.php?p=2049482#post2049482 (improves pr2,pr3 functionality)

This function is recommended, when you remaster or recompress old DVDs and try some sharpening, eg. with convolutional sharpeners.
You can use this function freely, but please when you store it somewhere, add the line "Hans' ringing remover".


Technical functionality:

What it does, it analyzes the signal and predicts one artifact to the left and one to the right of a vertical edge.
Each predicted artifact has a positive and a negative lobe, mimicking the decay of a wave form (each is one sample only, relying on proper filtering).
You can add the function a second time to tackle ringing with longer tails.
The placement of the artifact is set by the offset parameters.

The predicted artifact thus is being reengineered, and then subtracted from the original.
In other words, you have to set the weight parameters, and monitor the effect on a particular echo or Gibbs ringing structure.
The task is to zero in weight, not too light, not too dark, to make the artifacts less visible.
It is entirely possible to use negative weight, because some processes in recording and video production may cause negative artifact polarity.

Quality:

The quality level is DVD-related, but regarding Avisynth script processing, it was configured and tested with planar formats like YUV444 and 12-bit resolution.
I think we need also some upsampling. I routinely go from DVD to 960x640, then finally dithered down to 8bit for the mpeg encoding. Good compromise between quality and disk space.
Anti-ringing needs some finer granularity, I would not recommend to do the math at 8bit 720x480. YMMV.
Using the function at higher than double-size upsampling also is not recommended. Operating at 4k level would require to include wider signal windows and quadruple amount of autocorrelation/FIR signal taps (feel free to implement).
It has been tested successfully at 12-bit, 720x480, 960x640, 1500x960 (exact size does not matter but should be mod 4).
You can tackle different artifacts at different upsizing levels, take what fits best.
The correction should be applied on the Luma signal, but you can experiment as you like.

Hints for operation:

Find the dominant ringing distance first (change offset while watching a strong edge).
This means you look at the pixel distance between edge and artifact, and set offset to 1 .. 7, depending on which one is the most disturbing of several echoes.
In some cases, also in upsampled mode, offset values of 0 or even 5 to 7 may fit the issue. Negative offset is experimental, eg. for special effects.
Expect to run one or two instances with different offsets, and also at different resampling levels.

Tune in on a strongly visible artifact that is repetitive through the video. Start with a weight between 0.5 and 1.0, then try out offsets.
Great when you find out you can reduce weight values after getting the offsets right.

In short: Find the right offset, then nudge the weight of the corrective signal.
Negative weight can be used as well, because in video recording and processing, artifacts can come with switched polarity.
It may be rewarding to let some subtle remainder of ringing artifacts be handled by the x264 denoiser and not try to cancel out everything exactly.

Dynamic, protective operation:

The algorithm seeks to avoid blurring and also messing-up of small shapes in the image.
For this, pr2 and pr3 control protection of local signal structures at radius 2 and 3. Raising them reduces the chance that the filter will touch fine detail or text; lowering them makes the correction more aggressive. Recent tuning shows that moderate increases (e.g. 0.5–1.0) often improve legibility of on-screen text while still cleaning natural edges.

Hard-edged text and natural image content (skin, hair, fabric) respond differently. When text must remain readable, raise pr2/pr3 and/or knee; when only natural areas matter, lower protection is usually fine. A single global setting is always a compromise.

Actually, this tool has dynamic function and works on all parts of the frame that are prone to ringing or to thin echo lines.
Different scenes and different artifacts will ideally require different settings, so you want to find some averaged balance.
Properly tweaked, many videos will look remarkably cleaner and more brilliant.
Also, file-size can be reduced, when there are less artifacts in the signal.


When your workflow does some filtering, you may run into sub-pixel issues. If you can't get the offset right, you can try to resample to a higher resolution and apply the function there, with a higher offset.

Known limitations:

Best on vertical edges; horizontal ringing is only partially addressed.
In general, use the latest version, but in case you struggle with processing power, you can try earlier versions that e.g. work on half-side, half-wave.
Not a substitute for proper deinterlacing or pre-blend removal of sharpening of interlaced source.
The current version cannot easily target 4k formats, because in DVDs there is a tight relationship between resolution, size of shown structures and textures, and technical means to clean up the signal, each and every pixel counts.
For 4k operation, the windowing and autocorrelation functions would need to be rewritten to detect artifacts and shapes of a much bigger size.
Only the latest version is recommended for best results. Older versions may deliver only partial operation that uses less CPU.


Example:

* read data with MPEG2Source (),

* convert to 12-bit planar, whatever Expr() and other functions you are using can handle,

* also resize to 960x640 or higher)

* do some sharpening, preferably with linear convolution, the impulse may be asymmetric.

* at that point you can insert our function, be aware you may have to declare the data format.
last = AntiRing22LR(last, "luma", weightL=0.2,offsetL=3,weightR=0.2,offsetR=4,knee=0.3,pr2=0.5,pr3=0.5)

The "output" parameter when specified, is either a format string, or the literal "out=in" (try this in case of error messages), within the limits of Expr()
It was tested with a recent "avisynth plus 64bit" version, please make sure that you run a compatible version.

Thanks, please enjoy, have fun, have success!

earliest version:

function AntiRingLR(clip cl, string "planes", string "output", float "weightL", int "offsetL", float "weightR", int "offsetR", float "knee", float "pr2", float "pr3")
{ # --- Hans' Ringing Remover ---

weightL = Default(weightL, 0.25) # 0.15 .. 0.7, left intensity; 0.0 = inactive
offsetL = Default(offsetL,1) # 0..5, distance from an edge
weightR = Default(weightR, 0.25) # right side
offsetR = Default(offsetR,1)
knee = Default(knee,0.7) # 0.2 .. 1.5 lower = softer, more linear. higher = more on-off like, useful for printed text
pr2 = Default(pr2,0.5) # 0.0 .. 1.5 protection for signal components with radius=2
pr3 = Default(pr3,0.5) # 0.0 .. 1.5 protection for signal components with radius=3
# the higher you set the pr parameters, the less often the artifact remover will get triggered. it depends on the content of the frame.
#
# you invoke the function for one particular distance of an artifact to an edge, left, or right, or both.
# you can invoke it again with a different offset, if there is more than one strong artifact on the same side, but the instances may interact in unexpected ways.

expr = "x[2,0] x[1,0] - dup * .75 * x[1,0] x - dup * + x x[-1,0] - dup * + x[-1,0] x[-2,0] - dup * .75 * + sqrt _F1@ " +\
"x[3,0] x[1,0] - dup * .66 * x[2,0] x[0,0] - dup * + x[1,0] x[-1,0] - dup * + x[0,0] x[-2,0] - dup * .66 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[3,0] x[0,0] - dup * .5 * x[2,0] x[-1,0] - dup * + x[1,0] x[-2,0] - dup * + x[0,0] x[-3,0] - dup * .66 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(offsetL) +",0] x["+ String(2+offsetL) +",0] - 256.0 / * "+ String(weightL) +" * -64.0 max 127.0 min " +\
" _F1 " +\
"x[-3,0] x[-1,0] - dup * .66 * x[-2,0] x[0,0] - dup * + x[-1,0] x[1,0] - dup * + x[0,0] x[2,0] - dup * .66 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[-3,0] x[-0,0] - dup * .5 * x[-2,0] x[1,0] - dup * + x[-1,0] x[2,0] - dup * + x[0,0] x[3,0] - dup * .66 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(-offsetR) +",0] x["+ String(-2-offsetR) +",0] - 256.0 / * "+ String(weightR) +" * -64.0 max 127.0 min + x + "

planes = Default(planes, "luma")
Y = (planes == "luma" || planes == "all") ? 3 : 1
U = (planes == "chroma" || planes == "all") ? 3 : 1
V = (planes == "chroma" || planes == "all") ? 3 : 1

output = Default(output, "YUV444P12")
output = (output=="out=in") ? "" : output

if (output=="") {
return cl.Expr( (Y==3 ? expr : "x"), (U==3 ? expr : "x"), (V==3 ? expr : "x"), scale_inputs = "allf")
} else {
return cl.Expr( (Y==3 ? expr : "x"), (U==3 ? expr : "x"), (V==3 ? expr : "x"), scale_inputs = "allf", format=output)
}
}

Selur
5th April 2026, 16:24
Here's (https://pastebin.com/hLxQ3GY8) a Vapoursynth port, which doesn't use output parameter, internally uses YUV444PS and also has a AntiRingLRUD-wrapper which calls, AntiRingLR two time, where the second one is called on a 90° rotated version.

geometer
6th April 2026, 05:53
Thanks Selur,
I was planning to create an AntiRingUD() at a later time, as the systemic structure of halo and ringing is different for horizontal edges and lines.

On pre-blending, and regarding halo and ringing along horizontal bars and lines:
Currently, my own projects use different technologies to tackle that, the deinterlacer has a lot of influence in this. But hand-crafted convolutional impulses work quite well.
The issue is that the cameras deliver an interlaced video stream, but then later there is an attempt to sharpen it during the mastering, which does not work well.
Newer DVDs are naturally better with this.
You have to remove the sharpening in field mode (which I call pre-blending), then deinterlace, then sharpen again, and the halo to the bigger part will be gone.

I would be thankful to hear about usage experience, and am glad to answer questions.


This tool has quite some learning curve, though starting should be easy. See the artifact, find out its pixel distance from the edge, set weight to a high value like 0.8, then try to switch "offset" until the artifact gets covered by the predicted signal. Then, reduce the weight parameter and nudge, until this area looks as neutral as possible.
If you found good settings, the dynamics will find the same artifact through the whole frame, the whole scene, perhaps the whole video, and do the right thing.


You can save away presets, like
last = AntiRingLR(last, "luma", weightL=0.6,offsetL=1,weightR=0.6,offsetR=1,knee=0.5,pr2=0.3,pr3=0.6)
this one will create an effect halo around blurred objects.
You can clean up your signal, but you can also do very crazy things with it, when you pull the parameters out of the comfort zone or sweet spot.

I have added two examples where the sharpening had triggered massive ringing.

procedure:
...
open d2v file
deinterlace to 60fps
convolutional sharpening and corrections
...
apply the antiring function on 720x480 resolution like this:
last = AntiRingLR(last, "luma", weightL=0.11,offsetL=2,weightR=0.11,offsetR=3,knee=0.5,pr2=0.8,pr3=1.2)
...
do some more refinements with the help of upsampling
...
apply the antiring function on 960x640 resolution, use different offsets:
last = AntiRingLR(last, "luma", weightL=0.25,offsetL=1,weightR=0.2,offsetR=1,knee=0.5,pr2=0.5,pr3=0.5)
please note that this one was inserted and tuned first. it removes the most disturbing artifacts,
then the other one got inserted above, to tackle some extra issues for certain scenes.
---
the goal is also to run through the whole video with one or two instances of AntiRing,
and not change settings, because it is practically non-destructive,
but of course it is possible to cut and fine-tune by the particular scenes.
...
surrounding conditions:
pictures were screen shots from AvsPmod preview window (has poor filtering)
x264 plays a role with denoiser set to 25 and compression set to 19 or 20.
deinterlacing uses nnedi3 in double fps mode means the resulting video plays at 60fps.

geometer
22nd April 2026, 17:45
I am sharing what I do, so here is an update, with a limitation that does not bother me.

What it does, it adds the other lobe also, when it generates the predicted error signal.
This became apparent with some applications on videos with a bigger Gibbs ringing problem.
So, this version generates an impulse of one polarity at position offsetR from the edge, and then another impulse of opposite polarity at position offsetR+1.
Weight is currently (0.6 * weightR) fixed, because it works, you can change that easily.

But it adds that lobe only on the right side.
The reason is that I use asymmetric convolution impulses that can generate ringing tails on the right side only. On the left side there would normally be only one artifact.


function AntiRingLR2(clip cl, string "planes", string "output", float "weightL", int "offsetL", float "weightR", int "offsetR", float "knee", float "pr2", float "pr3")
{ # --- Hans' Ringing Remover ---

weightL = Default(weightL, 0.25) # 0.15 .. 0.7, left intensity; 0.0 = inactive
offsetL = Default(offsetL,1) # 0..5, distance from an edge (you can even go negative)
weightR = Default(weightR, 0.25) # right side
offsetR = Default(offsetR,1)
knee = Default(knee,0.7) # 0.2 .. 1.5 lower = softer, more linear. higher = more on-off like, useful for printed text
pr2 = Default(pr2,0.5) # 0.0 .. 1.5 protection for signal components with radius=2
pr3 = Default(pr3,0.5) # 0.0 .. 1.5 protection for signal components with radius=3
# the higher you set the pr parameters, the less often the artifact remover will get triggered. it depends on the content of the frame.
#
# you invoke the function for one particular distance of an artifact to an edge, left, or right, or both.
# you can invoke it again with a different offset, if there is more than one strong artifact on the same side, but the instances may interact in unexpected ways.

expr = "x[2,0] x[1,0] - dup * .75 * x[1,0] x - dup * + x x[-1,0] - dup * + x[-1,0] x[-2,0] - dup * .75 * + sqrt _F1@ " +\
"x[3,0] x[1,0] - dup * .66 * x[2,0] x[0,0] - dup * + x[1,0] x[-1,0] - dup * + x[0,0] x[-2,0] - dup * .66 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[3,0] x[0,0] - dup * .5 * x[2,0] x[-1,0] - dup * + x[1,0] x[-2,0] - dup * + x[0,0] x[-3,0] - dup * .66 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(offsetL) +",0] x["+ String(2+offsetL) +",0] - 256.0 / * "+ String(weightL) +" * -64.0 max 127.0 min " +\
" _F1 " +\
"x[-3,0] x[-1,0] - dup * .66 * x[-2,0] x[0,0] - dup * + x[-1,0] x[1,0] - dup * + x[0,0] x[2,0] - dup * .66 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[-3,0] x[-0,0] - dup * .5 * x[-2,0] x[1,0] - dup * + x[-1,0] x[2,0] - dup * + x[0,0] x[3,0] - dup * .66 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(-offsetR) +",0] x["+ String(-2-offsetR) +",0] - 256.0 / * "+ String(weightR) +" * -64.0 max 127.0 min + " +\
\
"x[1,0] x[0,0] - dup * .75 * x[0,0] x[-1,0] - dup * + x[-1,0] x[-2,0] - dup * + x[-2,0] x[-3,0] - dup * .75 * + sqrt " +\
"x[-4,0] x[-2,0] - dup * .66 * x[-3,0] x[-1,0] - dup * + x[-2,0] x[0,0] - dup * + x[-1,0] x[1,0] - dup * .66 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[-4,0] x[-1,0] - dup * .5 * x[-3,0] x[0,0] - dup * + x[-2,0] x[1,0] - dup * + x[-1,0] x[2,0] - dup * .66 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(-1-offsetR) +",0] x["+ String(-3-offsetR) +",0] - 256.0 / * "+ String(weightR*0.6) +" * -64.0 max 127.0 min - " +\
" x + "

planes = Default(planes, "luma")
Y = (planes == "luma" || planes == "all") ? 3 : 1
U = (planes == "chroma" || planes == "all") ? 3 : 1
V = (planes == "chroma" || planes == "all") ? 3 : 1

output = Default(output, "YUV444P12") ### 420?
output = (output=="out=in") ? "" : output

if (output=="") {
return cl.Expr( (Y==3 ? expr : "x"), (U==3 ? expr : "x"), (V==3 ? expr : "x"), scale_inputs = "allf")
} else {
return cl.Expr( (Y==3 ? expr : "x"), (U==3 ? expr : "x"), (V==3 ? expr : "x"), scale_inputs = "allf", format=output)
}
}



( I left the x[0,0] in for better readability in proof reading )

Here is a usage example.

At 720x480 in RGB planar, I apply a kernel like
C50g5u4="5 -19 33 -43 101 0 0 -4 2 -37 0 1 -3 7 9 0 0 -0 -1 -11 0 0 0 0 5"
last = Conv5x5(last, C50g5u4, "all", "out=in").ConvertToYUV444(matrix="PC.709", interlaced=false,chromaresample="lanczos4")

(this is extreme sharpening, with my own convolution function, very aggressive, normally you would not get away with that.)

then follows the first general ringing buster with
last = AntiRingLR2(last, "luma", weightL=0.23,offsetL=1,weightR=0.3,offsetR=1,knee=0.1,pr2=0.3,pr3=0.25)

then, we do some upsampling and sharpening in the upsampled domain, color correction, sometimes asharp() etc..

aside from the margin handling, the sample format ends up with 960x640.
and there we do a final
last = AntiRingLR2(last, "luma", weightL=0.15,offsetL=2,weightR=0.26,offsetR=5,knee=0.3,pr2=0.4,pr3=0.5)
because there is an extra artifact at the end of the tail.

I was lucky and results were stunning! :cool:
There are very few scenes with certain hatch patterns that would need extra attention, but it happens for a second only on a very small section of the screen.

---
addendum
Please notify me if you have difficulty to get it running.
You may have to take care of the pixel data format. I use 12bit planar.
Change the defaults in the code to your needs.
The following is distilled from my own workflow routine so you can see how I get to the format that works for me.
MPEG2Source (....)

# here would be the deinterlacer

ConvertBits(12)
ConvertToPlanarRGB(matrix="Rec709",interlaced=false)
ConvertToYUV444(matrix="PC.709", interlaced=false,chromaresample="lanczos4")

# ... various processings
# ...

last = AntiRingLR2(last, "luma", weightL=0.23,offsetL=1,weightR=0.3,offsetR=1,knee=0.1,pr2=0.3,pr3=0.25)
# if you use different formats, you may add output="out=in" to disable checking and attempted conversion

# ... upsampling and more processings
# ... maybe another AntiRing instance

ConvertBits(8, dither=1)

ConvertToYV12(chromaresample="lanczos4", matrix="Rec709", interlaced=false)

Prefetch(9)

return last

johnmeyer
23rd April 2026, 19:04
Impressive results.

Selur
24th April 2026, 13:00
AntiRingLR2 for Vapoursynth (https://github.com/Selur/VapoursynthScriptsInHybrid/blob/5d4b3458fd9c0799348ac069aeb11720f6a7a3ef/dering.py#L712).

Cu Selur

geometer
9th July 2026, 03:21
Finally, here is full 4-quadrant operation with double lobes left and right to edges.
It supports somewhat sharper results, and less distortion of shapes, when fine-tuned well.

Tuning this is a bit of a puzzle.
Set weight to 0.7 and offset to 2 and then increment offset. Watch out which areas it hits, which artifact lines it targets (mainly 2 adjacent structures, but for some images it is more complex).
If it becomes worse, switch polarity, or change offset.
After you have found your targets, keep the offsets and nudge weights so that the error cancels out. (too bright > too dark > somewhere in the middle).

You can run 2 instances so that "iatrogenic" i.e. new artifacts created by the algorithm, can cancel each other out.

Shifting offset may sometimes require switching polarity of the weight parameter.
It works better in upsampled domain, I use it mostly at 960x640, coming from DVD size.

I will gladly help you if you have questions.

function AntiRingL2R2(clip cl, string "planes", string "output", float "weightL", int "offsetL", float "weightR", int "offsetR", float "knee", float "pr2", float "pr3")
{
weightL = Default(weightL, 0.25) # 0.15 .. 0.7, weight also accepts negative values, as some ringing artifacts may have reversed polarity.
offsetL = Default(offsetL,1) # 1..6 , higher values are natural when you upsample
weightR = Default(weightR, 0.25)
offsetR = Default(offsetR,1)
knee = Default(knee,0.7) # 0.2 .. 1.5 lower = softer, more linear
pr2 = Default(pr2,0.5) # 0.0 .. 1.5 protection for signal component with radius=2, helps to avoid blurring or distortion around small, random shapes
pr3 = Default(pr3,0.5) # 0.0 .. 1.5 protection for signal component with radius=3

# output .. specify a format string, or set it to "out=in", else if will default to my preferred computational plane format.
# planes .. defaults to luma. you can set it to "all" as well. but this will yield probably ugly results with subsampled formats.

expr = "x[2,0] x[1,0] - dup * .75 * x[1,0] x - dup * + x x[-1,0] - dup * + x[-1,0] x[-2,0] - dup * .75 * + sqrt _F1@ " +\
"x[3,0] x[1,0] - dup * .45 * x[2,0] x[0,0] - dup * .77 * + x[1,0] x[-1,0] - dup * + x[0,0] x[-2,0] - dup * .77 * + x[-1,0] x[-3,0] - dup * .45 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[3,0] x[0,0] - dup * .5 * x[2,0] x[-1,0] - dup * + x[1,0] x[-2,0] - dup * + x[0,0] x[-3,0] - dup * .5 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(offsetL) +",0] x["+ String(2+offsetL) +",0] - 256.0 / * "+ String(weightL) +" * -64.0 max 127.0 min " +\
\
"x[3,0] x[2,0] - dup * .75 * x[2,0] x[1,0] - dup * + x[1,0] x[0,0] - dup * + x[0,0] x[-1,0] - dup * .75 * + sqrt " +\
"x[4,0] x[2,0] - dup * .66 * x[3,0] x[1,0] - dup * + x[2,0] x[0,0] - dup * + x[1,0] x[-1,0] - dup * .66 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[4,0] x[1,0] - dup * .66 * x[3,0] x[0,0] - dup * + x[2,0] x[-1,0] - dup * + x[1,0] x[-2,0] - dup * .5 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(1+offsetL) +",0] x["+ String(3+offsetL) +",0] - 256.0 / * "+ String(weightL*0.6) +" * -64.0 max 127.0 min + " +\
\
" _F1 " +\
\
"x[-3,0] x[-1,0] - dup * .45 * x[-2,0] x[0,0] - dup * .77 * + x[-1,0] x[1,0] - dup * + x[0,0] x[2,0] - dup * .77 * + x[1,0] x[3,0] - dup * .45 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[-3,0] x[-0,0] - dup * .5 * x[-2,0] x[1,0] - dup * + x[-1,0] x[2,0] - dup * + x[0,0] x[3,0] - dup * .5 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(-offsetR) +",0] x["+ String(-2-offsetR) +",0] - 256.0 / * "+ String(weightR) +" * -64.0 max 127.0 min + " +\
\
"x[1,0] x[0,0] - dup * .75 * x[0,0] x[-1,0] - dup * + x[-1,0] x[-2,0] - dup * + x[-2,0] x[-3,0] - dup * .75 * + sqrt " +\
"x[-4,0] x[-2,0] - dup * .66 * x[-3,0] x[-1,0] - dup * + x[-2,0] x[0,0] - dup * + x[-1,0] x[1,0] - dup * .66 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[-4,0] x[-1,0] - dup * .5 * x[-3,0] x[0,0] - dup * + x[-2,0] x[1,0] - dup * + x[-1,0] x[2,0] - dup * .66 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(-1-offsetR) +",0] x["+ String(-3-offsetR) +",0] - 256.0 / * "+ String(weightR*0.55) +" * -64.0 max 127.0 min - " +\
" x + "


planes = Default(planes, "luma")
Y = (planes == "luma" || planes == "all") ? 3 : 1
U = (planes == "chroma" || planes == "all") ? 3 : 1
V = (planes == "chroma" || planes == "all") ? 3 : 1

output = Default(output, "YUV444P12") ### 420?
output = (output=="out=in") ? "" : output

if (output=="") {
return cl.Expr( (Y==3 ? expr : "x"), (U==3 ? expr : "x"), (V==3 ? expr : "x"), scale_inputs = "allf")
} else {
return cl.Expr( (Y==3 ? expr : "x"), (U==3 ? expr : "x"), (V==3 ? expr : "x"), scale_inputs = "allf", format=output)
}
}

geometer
10th July 2026, 17:57
Here you can see a non-ideal case study.
(Please magnify the pics to full-screen each.)

The original is an extremely overcompressed DVD, 165 min in 4.5 GB.
The parameters weren't tuned for this frame in particular, but it was included in a general walk-through to find some meaningful settings.
Be aware it is a puzzle and needs some experimenting and practice.

The workflow is as following, I mention only the most significant steps.
(The 3 images were taken in reverse order, by switching off the corresponding functions from the script.)


* capture 3 - nnedi3() to 60fps, some general upsampling and color correction.
Some distortion in the little number plates may come from bobbing. The video players can smooth that out to the bigger part.

* capture 2 - as above, plus sharpening with a convolutional kernel, brickwall filtering.
kernel data (5x5 one-sided impulse):
"8 -17 33 -37 107 0 0 -4 2 -40 0 1 -3 7 7 0 0 -0 -1 -7 0 0 0 0 6"
This kernel should be fine-tuned for less ringing, but I leave it this way to make the imperfections and differences more visible.
Also, in the end result we see some macro halos (bigger radius), which could be tackled with convolution as well.

* capture 1 - as above, plus anti-ringing treatment, 2 instances
last = AntiRingL2R2(last, "luma", weightL=0.08,offsetL=2,weightR=0.35,offsetR=2,knee=0.25,pr2=0.2,pr3=0.2)
last = AntiRingL2R2(last, "luma", weightL=-0.23,offsetL=4,weightR=0.23,offsetR=4,knee=0.2,pr2=0.2,pr3=0.2)


As you can see, the difficulty is
a) the little numbers have approx. the same dimension as the ringing pattern. Even while contrast in this example suffers,
many digits look a little bit rounder and more precise after treatment. In full-size mode at least you can see how selective we can become.
When we crank up settings like in this case, it may happen that dark lines become a bit thicker. But also, the strong ringing might have made them thinner in the first place.
b) Most ringing that we see here is caused by MPEG-2 compression, but the algorithm is optimized more for the type of ringing in cameras, and from convolutional sharpening.
This has the consequence that we end up with a lot of additional grain and noise. At least most echo-lines or reflections will get scrambled, when they were quite dominant before.
I am using the denoiser in the x264 encoder to mitigate the remaining noise and artifacts. This is not visible here.
But it helps a lot, especially while the video is playing. The still pics are in that regard too pessimistic.

Computations are 12-bit planar, no color subsampling. This is by initial conversion to YUV444P12.

https://i.ibb.co/MynCLMKp/Clipboard03.png (https://ibb.co/TDcg5mJR)
https://i.ibb.co/Ngv10nHM/Clipboard02.png (https://ibb.co/0p7B0Vdx)
https://i.ibb.co/KzGzYCqf/Clipboard01.png (https://ibb.co/84B4JF5f)

------------

Different settings:
The previous example shows still a soft vertical line at a distance of 4-5 px from the edge.
offsetR was then incremented to 5, and it can tackle this a bit better.
The convolution was changed a bit to reduce some halo-like artifacts.

These are minor differences, to show how you can nudge the parameters.

"11 -19 33 -39 107 0 0 -4 2 -40 0 1 -3 7 7 0 0 -0 -1 -7 0 0 0 0 6"

last = AntiRing2LR(last, "luma", weightL=0.08,offsetL=2,weightR=0.3,offsetR=2,knee=0.25,pr2=0.2,pr3=0.2)
last = AntiRing2LR(last, "luma", weightL=-0.23,offsetL=4,weightR=0.35,offsetR=5,knee=0.2,pr2=0.2,pr3=0.2)

https://i.ibb.co/jPSMVhzn/Clipboard03.png (https://ibb.co/dwR2bBt8)
https://i.ibb.co/7dTW3CcN/Clipboard02.png (https://ibb.co/ycMyzPTB)
https://i.ibb.co/kVH5TCrr/Clipboard01.png (https://ibb.co/vCwVWnrr)

geometer
5th August 2026, 13:22
another example about the type of artifacts that we can remove.

this pic is from the DVD framework thread.
note that test4 means that the antiringing functions were switched off for the latest pass,
while test3 was the crafted mastering demo, saved one roundtrip earlier.

here, we first show the picture with sharpening but without the ringing clean-up,
and the cleaned-up version follows below that.

please, download and magnify, we are at a level of detail that may get lost when viewed in a browser window.

https://i.ibb.co/hrv81Gw/chokotest4.png (https://ibb.co/DmnKgBT)
https://i.ibb.co/39xH7Rxp/chokotest3.png (https://ibb.co/Q3WGfDWP)

hint:
removal of certain artifacts has also the advantage that the image looks sharper,
because these artifacts had cluttered up some detailed shapes in the first place.

geometer
24th August 2026, 16:49
Version update

Improvement in the protection of written text and small structures:
The center of the pr2 and pr3 probes that detect high local complexity were moved away from the edge by 1px.
For the left side artifact the complexity detector was moved one more px to the left, for the right side artifact, to the right.
Else the edge itself had triggered the probes too much, which would be self-defeating for this feature.
Basically this makes sense for offsets from 3 to 6, and when textual artwork is present and has font sizes around 6-10px.
Try to set pr2 and pr3 to 0.5 and iterate from there. Watch edges that stand alone, if the artifact is removed enough,
and then on the letters, check if they are not distorted too much. Higher values of pr will result in less aggressive correction.
Priority is given to analog-style image quality, not to text preservation.
Still, this function can mitigate problems with small fonts quite a bit.


function AntiRing22LR(clip cl, string "planes", string "output", float "weightL", int "offsetL", float "weightR", int "offsetR", float "knee", float "pr2", float "pr3")
{ # --- Hans' Ringing Remover ---

weightL = Default(weightL, 0.25) # 0.15 .. 1.5; left intensity; 0.0 = inactive; higher offsets often require higher weights, esp. in upscaling situation.
offsetL = Default(offsetL,1) # 1..7; distance from an edge; note that the target will gradually fall outside the signal window and function will diminish.
#this means, resolution remains limited, and is tuned according to NTSC DVD signal structure, you can upsample only so much.
weightR = Default(weightR, 0.25) # right side
offsetR = Default(offsetR,1)

knee = Default(knee,0.7) # 0.2 .. 1.5 lower = softer, more linear, higher = more on-off like.
pr2 = Default(pr2,0.5) # 0.0 .. 1.5 protection for local complex signal component with radius=2
pr3 = Default(pr3,0.5) # 0.0 .. 1.5 protection for signal component with radius=3

# higher values of pr2, pr3 will attenuate the corrective signal, at locations where local complexity is high.
# note that negative weights are allowed, it means that the corrective pen can be switched from bright to dark or vice versa

expr = "x[2,0] x[1,0] - dup * .75 * x[1,0] x - dup * + x x[-1,0] - dup * + x[-1,0] x[-2,0] - dup * .75 * + sqrt _F1@ " +\
\
"x[2,0] x[0,0] - dup * .45 * x[1,0] x[-1,0] - dup * .77 * + x[0,0] x[-2,0] - dup * + x[-1,0] x[-3,0] - dup * .77 * + x[-2,0] x[-4,0] - dup * .45 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[2,0] x[-1,0] - dup * .5 * x[1,0] x[-2,0] - dup * + x[0,0] x[-3,0] - dup * + x[-1,0] x[-4,0] - dup * .5 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(offsetL) +",0] x["+ String(2+offsetL) +",0] - 256.0 / * "+ String(weightL) +" * -64.0 max 127.0 min " +\
\
"x[3,0] x[2,0] - dup * .75 * x[2,0] x[1,0] - dup * + x[1,0] x[0,0] - dup * + x[0,0] x[-1,0] - dup * .75 * + sqrt " +\
"x[3,0] x[1,0] - dup * .66 * x[2,0] x[0,0] - dup * + x[1,0] x[-1,0] - dup * + x[0,0] x[-2,0] - dup * .66 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[3,0] x[0,0] - dup * .66 * x[2,0] x[-1,0] - dup * + x[1,0] x[-2,0] - dup * + x[0,0] x[-3,0] - dup * .5 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(1+offsetL) +",0] x["+ String(3+offsetL) +",0] - 256.0 / * "+ String(weightL*0.6) +" * -64.0 max 127.0 min + " +\
\
" _F1 " +\
\
"x[-2,0] x[0,0] - dup * .45 * x[-1,0] x[1,0] - dup * .77 * + x[0,0] x[2,0] - dup * + x[1,0] x[3,0] - dup * .77 * + x[2,0] x[4,0] - dup * .45 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[-2,0] x[1,0] - dup * .5 * x[-1,0] x[2,0] - dup * + x[0,0] x[3,0] - dup * + x[1,0] x[4,0] - dup * .5 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(-offsetR) +",0] x["+ String(-2-offsetR) +",0] - 256.0 / * "+ String(weightR) +" * -64.0 max 127.0 min + " +\
\
"x[1,0] x[0,0] - dup * .75 * x[0,0] x[-1,0] - dup * + x[-1,0] x[-2,0] - dup * + x[-2,0] x[-3,0] - dup * .75 * + sqrt " +\
"x[-3,0] x[-1,0] - dup * .66 * x[-2,0] x[0,0] - dup * + x[-1,0] x[1,0] - dup * + x[0,0] x[2,0] - dup * .66 * + sqrt " + String(pr2*0.36) + " * - " +\
"x[-3,0] x[0,0] - dup * .5 * x[-2,0] x[1,0] - dup * + x[-1,0] x[2,0] - dup * + x[0,0] x[3,0] - dup * .66 * + sqrt " + String(pr3*0.3) + " * - " +\
String(10.0/knee) + " / " + String(0.5*knee) + " - 0.0 max dup 1.0 - swap 1.0 + / 1.0 + 128.0 * 255.0 min " +\
"x["+ String(-1-offsetR) +",0] x["+ String(-3-offsetR) +",0] - 256.0 / * "+ String(weightR*0.55) +" * -64.0 max 127.0 min - " +\
" x + "


planes = Default(planes, "luma")
Y = (planes == "luma" || planes == "all") ? 3 : 1
U = (planes == "chroma" || planes == "all") ? 3 : 1
V = (planes == "chroma" || planes == "all") ? 3 : 1

output = Default(output, "YUV444P12") ### 420?
output = (output=="out=in") ? "" : output

if (output=="") {
return cl.Expr( (Y==3 ? expr : "x"), (U==3 ? expr : "x"), (V==3 ? expr : "x"), scale_inputs = "allf")
} else {
return cl.Expr( (Y==3 ? expr : "x"), (U==3 ? expr : "x"), (V==3 ? expr : "x"), scale_inputs = "allf", format=output)
}
}

Selur
25th August 2026, 03:21
side note: added AntiRingLR, AntiRingLRUD, AntiRingLR2, AntiRingL2R2, AntiRing22LR, AntiRingLR2UD to my dering.py (https://github.com/Selur/VapoursynthScriptsInHybrid/blob/master/dering.py) for Vapoursynth users.

geometer
25th August 2026, 04:42
thanks very much to Selur,
apologizing for the mess of function names,
actually only the latest one is recommended,
so far it is the most correct one with fullest features,
but others can be kept in case of CPU load issues.

...22LR means that there is a left and a right side to the edge with separate parameters,
and each side has a positive and a negative impulse lobe, switchable with "weight" polarity.